workspaces folders
This commit is contained in:
@@ -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}
|
||||
/>;
|
||||
});
|
||||
@@ -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>
|
||||
</>);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
});
|
||||
@@ -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>;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 >;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>);
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const chartRef = { current: document.createElement('span') };
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user