workspaces folders

This commit is contained in:
azykov@mail.ru
2026-06-12 11:46:14 +03:00
parent 88b36988ff
commit 36f76efdd7
102 changed files with 0 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
import { observer } from "mobx-react-lite";
import type { ObjectType, RuntimeGameObjectInstance } from "../types";
import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
import { useEffect, useMemo, useRef } from "react";
import { Euler, Quaternion, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei";
import { useRapier, useBeforePhysicsStep } from "@react-three/rapier";
import { joystickValues } from "../joystickInput";
import { state } from "../state";
const SPEED = 5;
const JUMP_SPEED = 8;
const GRAVITY = 20;
const SENSITIVITY = 0.002;
const LOOK_RATE = 2000;
type RapierWorldCreateCharacterControllerFunction = ReturnType<typeof useRapier>['world']['createCharacterController'];
type KinematicCharacterController = ReturnType<RapierWorldCreateCharacterControllerFunction>;
const _q = new Quaternion();
const _e = new Euler(0, 0, 0, 'YXZ');
const _offset = new Vector3();
const _charPos = new Vector3();
const _lookAt = new Vector3();
type PlayerObjectViewProps = {
object: RuntimeGameObjectInstance;
objectType: ObjectType;
}
export const PlayerObjectView = observer(function ({ object, objectType }: PlayerObjectViewProps) {
const handleRef = useRef<ObjectViewInternalHandle>(null);
const [, get] = useKeyboardControls();
const { gl } = useThree();
const { world } = useRapier();
const { shoulderOffset, lookAtY } = useMemo(() => {
const bb = object.cache.boundingBox;
const W = bb.max[0] - bb.min[0];
const H = bb.max[1] - bb.min[1];
const D = bb.max[2] - bb.min[2];
const radius = Math.sqrt(W * W + H * H + D * D);
const centerY = (bb.min[1] + bb.max[1]) / 2;
return {
shoulderOffset: new Vector3(W * 0.1, centerY + H * 0.3, bb.max[2] + radius),
lookAtY: centerY,
};
}, [object.cache.boundingBox]);
const yawRef = useRef(0);
const pitchRef = useRef(0);
const mouseRef = useRef({ x: 0, y: 0 });
const jumpPressedRef = useRef(false);
const vyRef = useRef(0);
const controllerRef = useRef<KinematicCharacterController | null>(null);
useEffect(() => {
const controller = world.createCharacterController(0.01);
controller.setUp({ x: 0, y: 1, z: 0 });
controller.setMaxSlopeClimbAngle(45 * Math.PI / 180);
controller.setMinSlopeSlideAngle(30 * Math.PI / 180);
controller.enableAutostep(0.5, 0.2, true);
controller.enableSnapToGround(0.5);
controller.setApplyImpulsesToDynamicBodies(true);
controllerRef.current = controller;
return () => { world.removeCharacterController(controller); };
}, [world]);
useEffect(() => {
const canvas = gl.domElement;
const onClick = () => { if (document.pointerLockElement !== canvas)
canvas.requestPointerLock()?.catch(() => {}); };
const onMouseMove = (e: MouseEvent) => {
if (document.pointerLockElement !== canvas)
return;
mouseRef.current.x += e.movementX;
mouseRef.current.y += e.movementY;
};
canvas.addEventListener('click', onClick);
document.addEventListener('mousemove', onMouseMove);
return () => {
canvas.removeEventListener('click', onClick);
document.removeEventListener('mousemove', onMouseMove);
};
}, [gl]);
useBeforePhysicsStep((world) => {
const rb = handleRef.current?.rb;
if (!rb)
return;
const collider = rb.collider(0);
if (!collider)
return;
const controller = controllerRef.current;
if (!controller)
return;
if (state.game?.isPaused)
return;
const dt = world.timestep;
const yaw = yawRef.current;
const { forward, backward, left, right, jump } = get();
const fwdX = -Math.sin(yaw);
const fwdZ = -Math.cos(yaw);
const fwdScale = Math.max(-1, Math.min(1, (forward ? 1 : 0) - (backward ? 1 : 0) + joystickValues.move.y));
const rightScale = Math.max(-1, Math.min(1, (right ? 1 : 0) - (left ? 1 : 0) + joystickValues.move.x));
const vx = (fwdX * fwdScale - fwdZ * rightScale) * SPEED;
const vz = (fwdZ * fwdScale + fwdX * rightScale) * SPEED;
const jumpInput = jump || joystickValues.jump;
const isGrounded = controller.computedGrounded();
if (jumpInput && !jumpPressedRef.current && isGrounded) {
vyRef.current = JUMP_SPEED;
} else if (!isGrounded) {
vyRef.current -= GRAVITY * dt;
} else {
vyRef.current = 0;
}
jumpPressedRef.current = jumpInput;
controller.computeColliderMovement(collider, { x: vx * dt, y: vyRef.current * dt, z: vz * dt });
const corrected = controller.computedMovement();
const t = rb.translation();
rb.setNextKinematicTranslation({
x: t.x + corrected.x,
y: t.y + corrected.y,
z: t.z + corrected.z,
});
_q.setFromEuler(_e.set(0, yaw, 0));
rb.setNextKinematicRotation({ x: _q.x, y: _q.y, z: _q.z, w: _q.w });
});
useFrame(({ camera }, delta) => {
const rb = handleRef.current?.rb;
if (!rb)
return;
mouseRef.current.x += joystickValues.look.x * LOOK_RATE * delta;
mouseRef.current.y += -joystickValues.look.y * LOOK_RATE * delta;
yawRef.current -= mouseRef.current.x * SENSITIVITY;
pitchRef.current = Math.max(-0.85, Math.min(0.5, pitchRef.current - mouseRef.current.y * SENSITIVITY));
mouseRef.current.x = 0;
mouseRef.current.y = 0;
const t = rb.translation();
_charPos.set(t.x, t.y, t.z);
_offset.copy(shoulderOffset).applyEuler(_e.set(pitchRef.current, yawRef.current, 0, 'YXZ'));
camera.position.copy(_charPos).add(_offset);
_lookAt.set(t.x, t.y + lookAtY, t.z);
camera.lookAt(_lookAt);
});
return (
<ObjectViewInternal
ref={handleRef}
object={object}
objectType={objectType}
isPlayer
/>
);
});
@@ -0,0 +1,29 @@
import { observer } from "mobx-react-lite";
import type { RuntimeGameObjectInstance } from "../types";
import { state } from "../state";
import { ObjectViewInternal } from "./ObjectViewInternal";
import { PlayerObjectView } from "./CharacterView";
export type GameObjectViewProps = {
object: RuntimeGameObjectInstance;
isPlayer: boolean;
}
export const GameObjectView = observer(function (props: GameObjectViewProps) {
const objectType = state.world.getObjectTypeById(props.object.typeId);
if (!objectType)
return null;
if (props.isPlayer)
return <PlayerObjectView object={props.object as RuntimeGameObjectInstance} objectType={objectType} />;
function handleClick(): void {
state.game?.emitEvent('object_clicked', { object: props.object });
}
return <ObjectViewInternal
{...props}
objectType={objectType}
onClick={handleClick}
/>;
});
+25
View File
@@ -0,0 +1,25 @@
import { observer } from "mobx-react-lite";
import { SceneView } from "./SceneView";
import { state } from "../state";
import { useFrame } from "@react-three/fiber";
import { Suspense } from "react";
import { Physics } from "@react-three/rapier";
export const GameView = observer(function () {
const game = state.game;
useFrame((_, delta) => {
state.game?.tick(delta);
});
if (!game)
return null;
return (<>
<Suspense>
<Physics paused={game.isPaused} debug>
<SceneView scene={game.scene} />
</Physics>
</Suspense>
</>);
});
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef } from "react";
import { create } from "nipplejs";
import { joystickValues } from "../joystickInput";
const isTouch = navigator.maxTouchPoints > 0;
// const isTouch = true; // debug
export function JoystickView() {
const moveZoneRef = useRef<HTMLDivElement>(null);
const jumpBtnRef = useRef<HTMLDivElement>(null);
const lookZoneRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isTouch)
return;
const moveZone = moveZoneRef.current;
const jumpBtn = jumpBtnRef.current;
const lookZone = lookZoneRef.current;
if (!moveZone || !jumpBtn || !lookZone)
return;
let moveJoystick: ReturnType<typeof create>;
let lookJoystick: ReturnType<typeof create>;
const setup = () => {
moveJoystick = create({
zone: moveZone,
mode: 'static',
position: { left: '80px', bottom: '80px' },
color: 'white',
shape: 'circle',
size: 100,
});
lookJoystick = create({
zone: lookZone,
mode: 'dynamic',
dataOnly: true,
});
moveJoystick.on('move', (evt) => {
joystickValues.move.x = evt.data.vector.x;
joystickValues.move.y = evt.data.vector.y;
});
moveJoystick.on('end', () => {
joystickValues.move = { x: 0, y: 0 };
});
lookJoystick.on('move', (evt) => {
joystickValues.look = evt.data.vector;
});
lookJoystick.on('end', () => {
joystickValues.look = { x: 0, y: 0 };
});
};
const teardown = () => {
moveJoystick?.destroy();
lookJoystick?.destroy();
joystickValues.move = { x: 0, y: 0 };
joystickValues.look = { x: 0, y: 0 };
joystickValues.jump = false;
};
setup();
const onJumpDown = () => { joystickValues.jump = true; };
const onJumpUp = () => { joystickValues.jump = false; };
jumpBtn.addEventListener('pointerdown', onJumpDown);
jumpBtn.addEventListener('pointerup', onJumpUp);
jumpBtn.addEventListener('pointercancel', onJumpUp);
jumpBtn.addEventListener('pointerleave', onJumpUp);
// When the OS interrupts a touch (app switch, notification, etc.) the browser
// fires pointercancel instead of pointerup, leaving nipplejs with a stuck active
// identifier. In dynamic mode this silently drops all subsequent touches on the
// look zone. Destroying and recreating resets nipplejs's internal touch tracking.
const onVisibilityChange = () => {
if (document.hidden) {
teardown();
} else {
setup();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
teardown();
jumpBtn.removeEventListener('pointerdown', onJumpDown);
jumpBtn.removeEventListener('pointerup', onJumpUp);
jumpBtn.removeEventListener('pointercancel', onJumpUp);
jumpBtn.removeEventListener('pointerleave', onJumpUp);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, []);
if (!isTouch)
return null;
return (
<>
<div
ref={moveZoneRef}
id="move-zone"
style={{ position: 'absolute', left: 0, bottom: 0, width: '30vw', height: '30vw', touchAction: 'none' }}
/>
<div
ref={lookZoneRef}
id="look-zone"
style={{ position: 'absolute', right: 0, bottom: 0, width: '100%', height: '100%', touchAction: 'none' }}
/>
<div
ref={jumpBtnRef}
id="jump-zone"
style={{
position: 'absolute',
right: 30,
bottom: 30,
width: 80,
height: 80,
borderRadius: '50%',
background: 'rgba(255,255,255,0.25)',
border: '2px solid rgba(255,255,255,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 14,
fontWeight: 'bold',
touchAction: 'none',
userSelect: 'none',
pointerEvents: 'auto',
}}
>
JUMP
</div>
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { observer } from "mobx-react-lite";
import { RenderInfoView } from "./RenderInfoView";
import { MenuView } from "./MenuView";
import { state } from "../state";
import { Panel } from "./Panel";
export const LeftPanel = observer(function () {
const isGame = state.game && !state.game.isPaused;
if (isGame)
return null;
// function handleLoadWorld(): void {
// state.world.load();
// }
function handleLoadMockWorld(): void {
state.world.loadMock();
}
return <Panel side='left'>
<MenuView />
<div className="gap" />
{/* <button onClick={handleLoadWorld}>Load</button> */}
<button onClick={handleLoadMockWorld}>Load mock world</button>
<div className="debug"><RenderInfoView /></div>
</Panel>;
});
+30
View File
@@ -0,0 +1,30 @@
import { observer } from "mobx-react-lite";
import { state } from "../state";
import { Panel } from "./Panel";
import { ScriptEditorView } from "./scriptEditor/ScriptEditorView";
export const MainPanel = observer(function () {
const isGame = state.game && !state.game.isPaused;
if (isGame)
return null;
const selectedObjectTypeId = state.worldEditor.selection?.type === 'objectType'
? state.worldEditor.selection.id
: undefined;
if (!selectedObjectTypeId)
return null;
const objectType = state.world.getObjectTypeById(selectedObjectTypeId);
if (!objectType)
return null;
const objects = Object.values(state.worldEditor.scene.objects)
.filter((o) => o.typeId === objectType.id);
return <Panel side='main'>
<header>
<div><span className="title">{objectType?.name}</span> ({objects.length} object{objects.length > 1 ? 's' : ''})</div>
</header>
<ScriptEditorView objectType={objectType} />
</Panel>;
});
+61
View File
@@ -0,0 +1,61 @@
.menu {
user-select: none;
font-size: 16px;
& details {
&>summary {
list-style: none;
&::-webkit-details-marker {
display: none;
}
display: flex;
cursor: pointer;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: rgba(255, 255, 255, 1);
&:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
&.selected {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
&>.title {
padding: 8px;
flex: 1;
overflow-x: hidden;
text-overflow: ellipsis;
}
&>.actions {
display: flex;
&>* {
padding: 8px;
}
&>*:hover {
background: rgba(255, 255, 255, 0.08);
}
}
& svg {
vertical-align: middle;
box-sizing: border-box;
}
}
& details {
padding-left: 12px;
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import { observer } from 'mobx-react-lite';
import { useEffect, useRef } from 'react';
import { state } from '../state';
import './MenuView.scss';
import type { MenuNode } from '../state/menuState';
import { useNavigate } from 'react-router-dom';
export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) {
const forceOpen = state.menu.nodeContainsSelected(node);
const navigate = useNavigate();
const ref = useRef<HTMLDetailsElement>(null);
useEffect(() => {
if (forceOpen && ref.current)
ref.current.open = true;
}, [forceOpen]);
const classNames = [];
if (node.selected?.())
classNames.push('selected');
if (node.className !== undefined)
classNames.push(node.className);
return (
<details ref={ref}>
<summary
className={classNames.join(' ')}
onClick={() => node.onClick?.()}
>
<div className="title">{node.title}</div>
{node.actions && <div className="actions">
{node.actions.map((action) =>
<div
key={action.id}
title={action.tooltip}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
action.onClick?.();
if (action.link)
navigate(action.link);
}}
>
{action.content}
</div>
)}
</div>}
</summary>
{node.children?.map((child) => <MenuNodeView key={child.id} node={child} />)}
</details>
);
});
export const MenuView = observer(function () {
return (
<nav className="menu">
{state.menu.nodes.map((node) => <MenuNodeView key={node.id} node={node} />)}
</nav>
);
});
@@ -0,0 +1,101 @@
import { observer } from "mobx-react-lite";
import type { ObjectInstance, R3, Runtime } from "../types";
import { useMemo, useRef, type RefObject } from "react";
import type { Group } from "three";
import { TransformControls, useHelper } from "@react-three/drei";
import { BoxHelper } from "three";
import { state } from "../state";
import { nextObjectEditMode } from "../state/worldEditorState";
import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
type ObjectEditorViewProps = {
object: Runtime<ObjectInstance>;
isPlayer: boolean;
}
type SelectionOverlayProps = {
objectId: string;
ref: RefObject<ObjectViewInternalHandle | null>;
onTransformEnd: () => void;
}
// Separate observer so only the 2 affected instances (selected/deselected)
// re-render on selection change, not all N objects in the scene.
const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: SelectionOverlayProps) {
const isSelected = state.worldEditor.isEnabled &&
state.worldEditor.selection?.type === 'object' &&
state.worldEditor.selection.id === objectId;
const selectionMode = isSelected &&
state.worldEditor.selection?.type === 'object'
? state.worldEditor.selection?.editMode
: undefined;
// Stable virtual ref that reads through to the group inside the handle
const groupRef = useMemo<RefObject<Group | null>>(
() => ({ get current() { return ref.current?.group ?? null; } }),
[ref],
);
useHelper(isSelected ? groupRef : { current: null }, BoxHelper, 'white');
if (!isSelected || selectionMode === undefined || !groupRef.current)
return null;
return (
<TransformControls
object={groupRef as RefObject<Group>}
mode={selectionMode}
onMouseUp={onTransformEnd}
/>
);
});
export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectEditorViewProps) {
const dataRef = useRef<ObjectViewInternalHandle>(null);
// Only observes world object types — not selection state — so won't
// re-render when a different object is selected.
const objectType = state.world.getObjectTypeById(object.typeId);
if (!objectType)
return null;
// Track cache so observer re-renders when refreshObjectTypeCaches is called
void object.cache.updatedAt;
function handleClick() {
if (state.worldEditor.isEnabled) {
// Reading selection state inside an event handler: not tracked by observer.
const currentMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === object.id
? state.worldEditor.selection?.editMode
: undefined;
state.worldEditor.setSelectedObject({ id: object.id, editMode: nextObjectEditMode(currentMode) });
}
}
function handleTransformEnd() {
const group = dataRef.current?.group;
if (group)
state.worldEditor.setObjectTransform(
object.id,
group.position.toArray(),
group.rotation.toArray().slice(0, 3) as R3,
group.scale.toArray(),
);
}
return (<>
<SelectionOverlay
objectId={object.id}
ref={dataRef}
onTransformEnd={handleTransformEnd}
/>
<ObjectViewInternal
ref={dataRef}
object={object}
isEditor
isPlayer={isPlayer}
objectType={objectType}
onClick={handleClick}
/>
</>);
});
@@ -0,0 +1,131 @@
import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types";
import { useEffect, useImperativeHandle, useRef, type Ref } from "react";
import { Euler, Quaternion, type Group } from "three";
import { Instance, Instances } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier";
import { SyncRigidBody } from "./SyncRigidBody";
import { reaction, runInAction } from "mobx";
import { v3toRapier } from "../utils";
export type ObjectViewInternalHandle = {
group: Group | null;
rb: RapierRigidBody | null;
}
type ObjectViewInternalProps = {
object: Omit<RuntimeObjectInstance, 'typeId'>;
objectType: ObjectType;
isEditor?: boolean;
isPlayer?: boolean;
onClick?: () => void;
ref?: Ref<ObjectViewInternalHandle>;
}
// not observer, instead reacts only to object.version field
export const ObjectViewInternal = function ({ object, objectType, ref, ...props }: ObjectViewInternalProps) {
const rbRef = useRef<RapierRigidBody>(null);
const groupRef = useRef<Group | null>(null);
useImperativeHandle(ref, () => ({ group: groupRef.current, rb: rbRef.current }));
useEffect(
() => {
if (props.isPlayer)
return;
return reaction(
() => 'version' in object ? object.version : 0,
() => {
if (!rbRef.current)
return;
const gameObj = object as RuntimeGameObjectInstance;
rbRef.current.setTranslation(v3toRapier(gameObj.position), true);
const euler = new Euler(gameObj.rotation[0], gameObj.rotation[1], gameObj.rotation[2]);
const q = new Quaternion().setFromEuler(euler);
rbRef.current.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true);
groupRef.current?.scale.set(...gameObj.scale);
rbRef.current.setLinvel(v3toRapier(gameObj.linearVelocity), true);
rbRef.current.setAngvel(v3toRapier(gameObj.angularVelocity), true);
},
);
},
[object, object.id, props.isPlayer]
);
const gameObj = object as RuntimeGameObjectInstance;
useEffect(
() => {
return reaction(
() => gameObj.pendingActions.impulse,
(impulse) => {
if (!impulse || !rbRef.current)
return;
// eslint-disable-next-line prefer-const
let { direction, amplitude } = impulse;
amplitude *= 100;
const v = { x: direction[0] * amplitude, y: direction[1] * amplitude, z: direction[2] * amplitude };
rbRef.current.applyImpulse(v, true);
runInAction(() => {
gameObj.pendingActions.impulse = undefined;
});
},
);
},
[gameObj.pendingActions, object.id]
);
function handleClick(e: ThreeEvent<MouseEvent>) {
if (e.delta > 5)
return;
e.stopPropagation();
props.onClick?.();
}
return (
<SyncRigidBody
ref={rbRef}
colliders={false}
type={props.isPlayer ? 'kinematicPosition' : (object.physics ? 'dynamic' : 'fixed')}
gravityScale={object.gravityScale}
position={object.position}
rotation={object.rotation}
onSync={(data) => {
runInAction(() => {
object.position = data.position;
object.rotation = data.rotation;
if (!props.isEditor) {
(object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity;
(object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity;
}
// do not change object.version to not trigger rerender
});
}}
>
<group
ref={groupRef}
name={`${object.id} (${objectType.id} instance)`}
scale={object.scale}
onClick={handleClick}
>
{
object.cache.voxelGroups.map((vg) =>
<Instances key={`${vg.id}-${object.cache.updatedAt}`} limit={vg.positions.length}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} />
{vg.positions.map((pos, i) => <Instance key={i} position={pos} />)}
</Instances>
)
}
{object.cache.colliderMesh && <ConvexHullCollider args={[object.cache.colliderMesh[0]]} />}
</group>
</SyncRigidBody>
);
};
+13
View File
@@ -0,0 +1,13 @@
import { observer } from "mobx-react-lite";
import type { ReactNode } from "react";
export type PanelProps = {
side?: 'left' | 'right' | 'main';
children?: ReactNode;
}
export const Panel = observer(function ({ children, side = 'left' }: PanelProps) {
return <div className={`panel ${side}`}>
{children}
</div >;
});
+97
View File
@@ -0,0 +1,97 @@
.overlay-panels {
z-index: 15000;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
gap: 10px;
pointer-events: none;
&>.panel {
box-sizing: border-box;
margin: 10px;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
padding: 4px;
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 0.25em;
&:not(:first-child) {
margin-left: none !important;
}
&.left,
&.right {
min-width: 30%;
flex: 0;
}
&.main {
flex: 1;
pointer-events: all;
}
&>header {
background: #00000040;
margin: -4px;
padding: 12px;
&>.title {
text-transform: capitalize;
}
}
&>*:not(.debug):not(.header):not(.gap) {
pointer-events: all;
}
&>.gap {
flex: 1;
}
&>.debug {
// border: 1px solid #ffffff20;
border-radius: 2px;
background: #00000040;
margin: -4px;
padding: 4px;
font-size: 75%;
display: flex;
gap: 0.5em;
align-items: start;
&>.chart {
display: inline;
& div {
display: inline;
position: relative !important;
z-index: unset !important;
opacity: 1 !important;
}
}
&>.metrics {
display: flex;
flex-wrap: wrap;
gap: 0.25em 0.5em;
&>* {
white-space: pre;
}
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { observer } from "mobx-react-lite";
import { LeftPanel } from "./LeftPanel";
import { MainPanel } from "./MainPanel";
import './Panels.scss';
export const Panels = observer(function () {
return <div className="overlay-panels">
<LeftPanel />
<MainPanel />
</div>;
});
@@ -0,0 +1,30 @@
import { useEffect, useRef } from "react";
import { observer } from "mobx-react-lite";
import { state } from "../state";
import { chartRef } from "./chartRef";
export const RenderInfoView = observer(function () {
const info = state.renderInfo;
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = containerRef.current;
if (!el)
return;
el.appendChild(chartRef.current);
return () => { el.removeChild(chartRef.current); };
}, []);
return <>
<div className="chart" ref={containerRef} />
{info != null && (
<div className="metrics">
<span>draw: {info.calls}</span>
<span>tri: {info.triangles}</span>
<span>shaders: {info.shaders}</span>
<span>geometries: {info.geometries}</span>
<span>textures: {info.textures}</span>
</div>
)}
</>;
});
@@ -0,0 +1,66 @@
import { Suspense, useRef } from 'react';
import type React from 'react';
import { Grid, OrbitControls } from '@react-three/drei';
import { observer } from 'mobx-react-lite';
import { state } from '../state';
import { SceneView } from './SceneView';
import { type OrthographicCamera, type PerspectiveCamera } from 'three';
import { CameraSync } from './tools/CameraSync';
import { Physics } from '@react-three/rapier';
export const SceneEditorView = observer(function () {
const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null);
const cameraAtStart = useRef<string>('');
function cameraSnapshot(camera: OrthographicCamera | PerspectiveCamera): string {
return JSON.stringify({
position: camera.position,
rotation: camera.rotation,
});
}
const handleStart = () => {
const controls = controlsRef.current;
if (!controls)
return;
cameraAtStart.current = cameraSnapshot(controls.object);
};
const handleEnd = () => {
const controls = controlsRef.current;
if (!controls)
return;
const snapshot = cameraSnapshot(controls.object);
if (snapshot === cameraAtStart.current)
return;
const [x, y, z] = controls.object.rotation.toArray();
state.worldEditor.setCamera({
position: controls.object.position.toArray(),
look: [x, y, z],
});
};
return (<>
<Suspense>
<OrbitControls
ref={controlsRef}
onStart={handleStart}
onEnd={handleEnd}
makeDefault
enableDamping={false}
/>
<CameraSync camera={state.worldEditor.camera} />
<Grid
sectionThickness={0.5}
cellThickness={0}
sectionColor="white"
infiniteGrid
/>
<Physics colliders={false} paused={true}>
<SceneView scene={state.worldEditor.scene} editMode />
</Physics>
</Suspense>
</>);
});
+23
View File
@@ -0,0 +1,23 @@
import { observer } from "mobx-react-lite";
import type { RuntimeScene } from "../types";
import { GameObjectView } from "./GameObjectView";
import { ObjectEditorView } from "./ObjectEditorView";
type SceneViewProps = {
scene: RuntimeScene,
editMode?: boolean;
}
export const SceneView = observer(function (props: SceneViewProps) {
return (<>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} />
{
Object.values(props.scene.objects).map((obj) => (
props.editMode
? <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
: <GameObjectView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
))
}
</>);
});
@@ -0,0 +1,78 @@
import { RigidBody, type RigidBodyProps, type RapierRigidBody } from "@react-three/rapier";
import { useFrame } from "@react-three/fiber";
import { useImperativeHandle, useRef, type Ref } from "react";
import { Euler, Quaternion } from "three";
import type { R3, V3 } from "../types";
export type SyncRigidBodyData = {
position: V3;
rotation: R3;
linearVelocity: V3;
radialVelocity: R3;
}
export type SyncRigidBodyOnSyncFunction = (data: SyncRigidBodyData) => void;
type SyncRigidBodyProps = RigidBodyProps & {
onSync: SyncRigidBodyOnSyncFunction;
ref?: Ref<RapierRigidBody | null>;
};
const _q = new Quaternion();
const _e = new Euler();
const EPS = 1e-6;
// indices: 0-2 position, 3-5 rotation, 6-8 linearVelocity, 9-11 radialVelocity
const PREV_INIT = new Float64Array(12).fill(Infinity);
function syncRigidBodyDataToArray(data: SyncRigidBodyData, arr: Float64Array): void {
arr[0] = data.position[0]; arr[1] = data.position[1]; arr[2] = data.position[2];
arr[3] = data.rotation[0]; arr[4] = data.rotation[1]; arr[5] = data.rotation[2];
arr[6] = data.linearVelocity[0]; arr[7] = data.linearVelocity[1]; arr[8] = data.linearVelocity[2];
arr[9] = data.radialVelocity[0]; arr[10] = data.radialVelocity[1]; arr[11] = data.radialVelocity[2];
}
function compareTwoFloatArrays(a: Float64Array, b: Float64Array, epsilon: number): boolean {
for (let i = 0; i < a.length; i++)
if (Math.abs(a[i] - b[i]) > epsilon)
return true;
return false;
}
export function SyncRigidBody({ onSync, ref, children, ...props }: SyncRigidBodyProps) {
const rbRef = useRef<RapierRigidBody>(null);
useImperativeHandle(ref, () => rbRef.current!);
const prevData = useRef<Float64Array>(PREV_INIT.slice());
const currentData = useRef<Float64Array>(PREV_INIT.slice());
useFrame(() => {
const body = rbRef.current;
if (!body)
return;
const { x, y, z } = body.translation();
const rot = body.rotation();
_q.set(rot.x, rot.y, rot.z, rot.w);
_e.setFromQuaternion(_q);
const lv = body.linvel();
const av = body.angvel();
const data: SyncRigidBodyData = {
position: [x, y, z],
rotation: [_e.x, _e.y, _e.z],
linearVelocity: [lv.x, lv.y, lv.z],
radialVelocity: [av.x, av.y, av.z],
};
syncRigidBodyDataToArray(data, currentData.current);
if (compareTwoFloatArrays(currentData.current, prevData.current, EPS)) {
prevData.current.set(currentData.current);
onSync(data);
}
});
return <RigidBody ref={rbRef} {...props}>{children}</RigidBody>;
}
+50
View File
@@ -0,0 +1,50 @@
import { Canvas } from '@react-three/fiber';
import { KeyboardControls, Stats } from '@react-three/drei';
import { chartRef } from './chartRef';
import { observer } from 'mobx-react-lite';
import { state } from '../state';
import { GameView } from './GameView';
import { SceneEditorView } from './SceneEditorView';
import { JoystickView } from './JoystickView';
import type { RefObject } from 'react';
import { IconPlayerPlayFilled, IconPlayerPauseFilled, IconPlayerStopFilled } from '@tabler/icons-react';
import { RenderInfoUpdater } from './renderInfo';
export const ThreeView = observer(function () {
const isGame = !!state.game;
return (
<KeyboardControls map={[
{ name: 'forward', keys: ['ArrowUp', 'w', 'W'] },
{ name: 'backward', keys: ['ArrowDown', 's', 'S'] },
{ name: 'left', keys: ['ArrowLeft', 'a', 'A'] },
{ name: 'right', keys: ['ArrowRight', 'd', 'D'] },
{ name: 'jump', keys: ['Space'] },
]}>
<div style={{ position: 'fixed', inset: 0, overflow: 'hidden' }}>
<Canvas
// camera={state.world.character.camera}
onPointerMissed={() => state.worldEditor.resetSelection()}
>
<Stats parent={chartRef as RefObject<HTMLElement>} />
<RenderInfoUpdater />
{isGame ? <GameView /> : <SceneEditorView />}
</Canvas>
{isGame && <JoystickView />}
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4, fontSize: 14 }}>
{
state.game
? <>
<button onClick={() => state.stopGame()}><IconPlayerStopFilled size="1em" /></button>
{
state.game!.isPaused
? <button onClick={() => state.game!.resume()}><IconPlayerPlayFilled size="1em" /></button>
: <button onClick={() => state.game!.pause()}><IconPlayerPauseFilled size="1em" /></button>
}
</>
: <button onClick={() => state.startGame()}><IconPlayerPlayFilled size="1em" /></button>
}
</div>
</div>
</KeyboardControls>
);
});
+1
View File
@@ -0,0 +1 @@
export const chartRef = { current: document.createElement('span') };
+20
View File
@@ -0,0 +1,20 @@
import { useFrame, useThree } from "@react-three/fiber";
import { state } from "../state";
import { action } from "mobx";
export function RenderInfoUpdater() {
const { gl } = useThree();
useFrame(action(() => {
const { calls, triangles } = gl.info.render;
const shaders = gl.info.programs?.length ?? 0;
const { geometries, textures } = gl.info.memory;
state.setRenderInfo({
calls,
triangles,
shaders,
geometries,
textures,
});
}));
return null;
}
@@ -0,0 +1,11 @@
.script-editor {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.blockly-container {
flex: 1;
min-height: 0;
}
}
@@ -0,0 +1,142 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { observer } from "mobx-react-lite";
import { runInAction } from "mobx";
import * as Blockly from "blockly";
import type { ObjectType } from "../../types";
import './ScriptEditorView.scss';
import '../../blockly';
import { gameTheme, generateCode } from '../../blockly';
const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = {
kind: 'categoryToolbox',
contents: [
{
kind: 'category',
name: 'Events',
colour: '180',
contents: [
{ kind: 'block', type: 'game_start_event' },
{ kind: 'block', type: 'object_touch_start_event' },
{ kind: 'block', type: 'player_touch_start_event' },
{ kind: 'block', type: 'object_clicked_event' },
],
},
{
kind: 'category',
name: 'Values',
colour: '260',
contents: [
{ kind: 'block', type: 'current_object_value' },
{ kind: 'block', type: 'current_object_type_value' },
{ kind: 'block', type: 'object_type_value' },
{ kind: 'block', type: 'any_object_of_type_value' },
{ kind: 'block', type: 'position_of_object_value' },
{ kind: 'block', type: 'pos_value' },
{ kind: 'block', type: 'dpos_value' },
{ kind: 'block', type: 'direction_to_position_value' },
{ kind: 'block', type: 'object_by_id_value' },
{ kind: 'block', type: 'player_object_value' },
],
},
{
kind: 'category',
name: 'Actions',
colour: '20',
contents: [
{ kind: 'block', type: 'console_log_action' },
{ kind: 'block', type: 'physics_apply_impulse_action' },
{ kind: 'block', type: 'wait_seconds_action' },
],
},
{
kind: 'category',
name: 'Text',
colour: '160',
contents: [
{ kind: 'block', type: 'text' },
],
},
],
};
type ScriptEditorViewProps = {
objectType: ObjectType;
}
export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const objectTypeRef = useRef(objectType);
useLayoutEffect(() => { objectTypeRef.current = objectType; });
useEffect(() => {
if (!containerRef.current)
return;
const workspace = Blockly.inject(containerRef.current, { toolbox: TOOLBOX, theme: gameTheme });
workspaceRef.current = workspace;
const listener = (e: Blockly.Events.Abstract) => {
if (e.isUiEvent)
return;
runInAction(() => {
const current = objectTypeRef.current;
const serialized = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
current.program = serialized;
try {
current.javascript = generateCode(workspace);
}
catch (err) {
console.log(err);
current.javascript = undefined;
}
console.log(current.javascript);
});
};
workspace.addChangeListener(listener);
if (objectType.program) {
try {
Blockly.Events.disable();
Blockly.serialization.workspaces.load(JSON.parse(objectType.program), workspace);
} catch {
// corrupt program, start fresh
} finally {
Blockly.Events.enable();
}
}
const resizeObserver = new ResizeObserver(() => Blockly.svgResize(workspace));
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
workspace.removeChangeListener(listener);
workspace.dispose();
workspaceRef.current = null;
};
}, [objectType.program]);
useEffect(() => {
const workspace = workspaceRef.current;
if (!workspace)
return;
try {
Blockly.Events.disable();
workspace.clear();
if (objectType.program)
Blockly.serialization.workspaces.load(JSON.parse(objectType.program), workspace);
} catch {
// corrupt program, start fresh
} finally {
Blockly.Events.enable();
}
}, [objectType.id, objectType.program]);
return <div className="script-editor">
<div ref={containerRef} className="blockly-container" />
</div>;
});
@@ -0,0 +1,16 @@
import { useThree } from "@react-three/fiber";
import type { Pos3 } from "../../types";
import { useLayoutEffect } from "react";
import { observer } from "mobx-react-lite";
export const CameraSync = observer(function ({ camera }: { camera: Pos3 }) {
const { camera: threeCamera } = useThree();
useLayoutEffect(() => {
threeCamera.position.set(camera.position[0], camera.position[1], camera.position[2]);
threeCamera.rotation.set(camera.look[0], camera.look[1], camera.look[2]);
threeCamera.updateProjectionMatrix();
});
return null;
});
@@ -0,0 +1,199 @@
import { Canvas } from '@react-three/fiber';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { observer } from 'mobx-react-lite';
import { useState, useCallback, useEffect, useRef } from 'react';
import { state } from '../../state';
import type { Voxel, V3 } from '../../types';
import { VoxelEditorScene } from './VoxelEditorScene';
import { IconArrowLeft, IconArrowBackUp, IconArrowForwardUp, IconPlus, IconTrash } from '@tabler/icons-react';
const PALETTE = [
'#ffffff', '#cccccc', '#999999', '#555555', '#222222',
'#e05555', '#e08844', '#e0cc44', '#55cc55', '#55cccc',
'#5588e0', '#aa55e0', '#a0522d', '#228b22', '#c06858',
'#e8b820', '#484040', '#d0c8b8', '#808080', '#181414',
];
type HistoryEntry =
| { type: 'add'; voxel: Voxel }
| { type: 'remove'; voxel: Voxel };
export const VoxelEditorPage = observer(function () {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [mode, setMode] = useState<'add' | 'remove'>('add');
const [color, setColor] = useState('#808080');
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const history = useRef<HistoryEntry[]>([]);
const future = useRef<HistoryEntry[]>([]);
const objectType = id ? state.world.getObjectTypeById(id) : undefined;
useEffect(() => {
return () => {
if (id)
state.worldEditor.refreshObjectTypeCaches(id);
};
}, [id]);
function pushHistory(entry: HistoryEntry) {
history.current.push(entry);
future.current = [];
setCanUndo(true);
setCanRedo(false);
}
const undo = useCallback(() => {
const entry = history.current.pop();
if (!entry || !id)
return;
if (entry.type === 'add') {
state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position);
} else {
state.worldEditor.addVoxelToObjectType(id, entry.voxel);
}
future.current.push(entry);
setCanUndo(history.current.length > 0);
setCanRedo(true);
}, [id]);
const redo = useCallback(() => {
const entry = future.current.pop();
if (!entry || !id)
return;
if (entry.type === 'add') {
state.worldEditor.addVoxelToObjectType(id, entry.voxel);
} else {
state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position);
}
history.current.push(entry);
setCanUndo(true);
setCanRedo(future.current.length > 0);
}, [id]);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (!e.ctrlKey)
return;
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); redo(); }
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [undo, redo]);
function handleAdd(voxel: Voxel) {
if (!id)
return;
state.worldEditor.addVoxelToObjectType(id, voxel);
pushHistory({ type: 'add', voxel });
}
function handleRemove(position: V3) {
if (!id || !objectType)
return;
const voxel = objectType.voxels.find(v =>
v.position[0] === position[0] &&
v.position[1] === position[1] &&
v.position[2] === position[2]
);
if (!voxel)
return;
state.worldEditor.removeVoxelFromObjectType(id, position);
pushHistory({ type: 'remove', voxel });
}
if (!objectType) {
return (
<div style={{ color: 'white', padding: 24 }}>
Object type not found. <Link to="/editor" style={{ color: '#88aaff' }}>Back to editor</Link>
</div>
);
}
return (
<div style={{ position: 'fixed', inset: 0, background: '#1a1a2e', display: 'flex', flexDirection: 'column' }}>
{/* Top bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: '#11111f', flexShrink: 0 }}>
<button onClick={() => navigate(`/editor/objectType/${id}`)} style={btnStyle}>
<IconArrowLeft size={20} />
</button>
<span style={{ color: '#ccc', fontWeight: 600 }}>{objectType.name}</span>
<span style={{ color: '#666', fontSize: 13 }}>{objectType.voxels.length} voxels</span>
<div style={{ flex: 1 }} />
<button onClick={undo} disabled={!canUndo} style={{ ...btnStyle, opacity: canUndo ? 1 : 0.35 }}>
<IconArrowBackUp size={20} />
</button>
<button onClick={redo} disabled={!canRedo} style={{ ...btnStyle, opacity: canRedo ? 1 : 0.35 }}>
<IconArrowForwardUp size={20} />
</button>
</div>
{/* 3D canvas */}
<div style={{ flex: 1, position: 'relative' }}>
<Canvas camera={{ position: [12, 10, 12], fov: 50 }}>
<VoxelEditorScene
voxels={objectType.voxels}
mode={mode}
color={color}
typeId="stone"
onAdd={handleAdd}
onRemove={handleRemove}
/>
</Canvas>
</div>
{/* Bottom bar */}
<div style={{ background: '#11111f', padding: '10px 12px', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Color palette */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{PALETTE.map(c => (
<button
key={c}
onClick={() => { setColor(c); setMode('add'); }}
style={{
width: 36, height: 36,
background: c,
borderRadius: 6,
border: c === color ? '3px solid white' : '3px solid #333',
cursor: 'pointer',
flexShrink: 0,
}}
/>
))}
</div>
{/* Mode toggle */}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => setMode('add')}
style={{ ...btnStyle, flex: 1, padding: '12px 0', gap: 6, background: mode === 'add' ? '#2d6a2d' : '#2a2a3a', border: mode === 'add' ? '2px solid #4CAF50' : '2px solid #444' }}
>
<IconPlus size={18} /> Add
</button>
<button
onClick={() => setMode('remove')}
style={{ ...btnStyle, flex: 1, padding: '12px 0', gap: 6, background: mode === 'remove' ? '#6a2d2d' : '#2a2a3a', border: mode === 'remove' ? '2px solid #f44336' : '2px solid #444' }}
>
<IconTrash size={18} /> Remove
</button>
</div>
</div>
</div>
);
});
const btnStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#2a2a3a',
color: '#ccc',
border: '2px solid #444',
borderRadius: 8,
padding: '6px 10px',
cursor: 'pointer',
fontSize: 14,
gap: 4,
};
@@ -0,0 +1,125 @@
import { OrbitControls } from '@react-three/drei';
import { useState } from 'react';
import type { ThreeEvent } from '@react-three/fiber';
import type { Voxel, V3 } from '../../types';
type Props = {
voxels: Voxel[];
mode: 'add' | 'remove';
color: string;
typeId: string;
onAdd: (voxel: Voxel) => void;
onRemove: (position: V3) => void;
};
const FLOOR_Y = 0;
function adjPosition(pos: V3, nx: number, ny: number, nz: number): V3 {
return [pos[0] + Math.round(nx), pos[1] + Math.round(ny), pos[2] + Math.round(nz)];
}
function posEq(a: V3, b: V3) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}
const DRAG_THRESHOLD = 5;
export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove }: Props) {
const [ghost, setGhost] = useState<V3 | null>(null);
function handleVoxelClick(e: ThreeEvent<MouseEvent>, voxel: Voxel) {
if (e.delta > DRAG_THRESHOLD)
return;
e.stopPropagation();
if (mode === 'remove') {
onRemove(voxel.position);
setGhost(null);
} else {
if (!e.face)
return;
const { x, y, z } = e.face.normal;
const pos = adjPosition(voxel.position, x, y, z);
onAdd({ typeId, position: pos, color });
}
}
function handleVoxelMove(e: ThreeEvent<PointerEvent>, voxel: Voxel) {
e.stopPropagation();
if (mode === 'remove') {
setGhost(voxel.position);
} else {
setGhost(null);
}
}
function handleFloorClick(e: ThreeEvent<MouseEvent>) {
if (e.delta > DRAG_THRESHOLD || mode !== 'add')
return;
e.stopPropagation();
const pos: V3 = [Math.floor(e.point.x), 0, Math.floor(e.point.z)];
onAdd({ typeId, position: pos, color });
}
function handleFloorMove(e: ThreeEvent<PointerEvent>) {
if (mode !== 'add')
return;
setGhost([Math.floor(e.point.x), 0, Math.floor(e.point.z)]);
}
const ghostOccupied = ghost && voxels.some(v => posEq(v.position, ghost));
return (
<>
<ambientLight intensity={0.7} />
<directionalLight position={[8, 12, 6]} intensity={1} castShadow />
<OrbitControls makeDefault />
{/* Floor plane */}
<mesh
rotation={[-Math.PI / 2, 0, 0]}
position={[0, FLOOR_Y, 0]}
onClick={handleFloorClick}
onPointerMove={handleFloorMove}
onPointerLeave={() => setGhost(null)}
>
<planeGeometry args={[40, 40]} />
<meshStandardMaterial color="#2a2a3a" />
</mesh>
<gridHelper args={[40, 40, '#555', '#333']} position={[0, FLOOR_Y, 0]} />
{/* Voxels */}
{voxels.map((v, i) => {
const isHovered = !!ghost && mode === 'remove' && posEq(v.position, ghost);
return (
<mesh
key={i}
position={[v.position[0] + 0.5, v.position[1] + 0.5, v.position[2] + 0.5]}
onClick={(e) => handleVoxelClick(e, v)}
onPointerMove={(e) => handleVoxelMove(e, v)}
onPointerLeave={() => setGhost(null)}
>
<boxGeometry />
<meshStandardMaterial
color={v.color ?? '#888888'}
opacity={isHovered ? 0.4 : 1}
transparent={isHovered}
/>
</mesh>
);
})}
{/* Ghost voxel preview */}
{ghost && mode === 'add' && !ghostOccupied && (
<mesh
position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]}
raycast={() => null}
>
<boxGeometry />
<meshStandardMaterial color={color} opacity={0.45} transparent />
</mesh>
)}
</>
);
}