This commit is contained in:
azykov@mail.ru 2026-06-11 11:34:12 +03:00
parent 3bdde094f8
commit 88b36988ff
No known key found for this signature in database
34 changed files with 150 additions and 116 deletions

View File

@ -19,13 +19,12 @@ export default defineConfig([
globals: globals.browser,
},
rules: {
"padding-line-between-statements": [
"warn",
{ blankLine: 'always', prev: '*', next: 'block' },
{ blankLine: 'always', prev: 'block', next: '*' },
{ blankLine: 'always', prev: '*', next: 'block-like' },
{ blankLine: 'always', prev: 'block-like', next: '*' },
]
"semi": ["error", "always"],
"nonblock-statement-body-position": ["error", "below"],
"@typescript-eslint/no-unused-vars": ["error", {
"varsIgnorePattern": "^_",
"argsIgnorePattern": "^_",
}],
},
},
])

View File

@ -1,6 +1,6 @@
import { BrowserRouter, Link, Outlet, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom';
import { VoxelEditorPage } from './components/voxelEditor/VoxelEditorPage';
import { useEffect, useRef } from 'react';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { reaction } from 'mobx';
import { ThreeView } from './components/ThreeView';
import { state } from './state';
@ -11,7 +11,7 @@ function StateToUrlSync() {
const navigate = useNavigate();
const { pathname } = useLocation();
const pathnameRef = useRef(pathname);
pathnameRef.current = pathname;
useLayoutEffect(() => { pathnameRef.current = pathname; });
useEffect(() => reaction(
() => ({
@ -21,22 +21,28 @@ function StateToUrlSync() {
}),
({ isGame, selectedObjectId, selectedObjectTypeId }) => {
let target: string;
if (isGame) target = '/game';
else if (selectedObjectId) target = `/editor/object/${selectedObjectId}`;
else if (selectedObjectTypeId) target = `/editor/objectType/${selectedObjectTypeId}`;
else target = '/editor';
if (isGame)
target = '/game';
else if (selectedObjectId)
target = `/editor/object/${selectedObjectId}`;
else if (selectedObjectTypeId)
target = `/editor/objectType/${selectedObjectTypeId}`;
else
target = '/editor';
const current = pathnameRef.current;
if (current === target || current.startsWith(target + '/')) return;
if (current === target || current.startsWith(target + '/'))
return;
navigate(target);
},
), []);
), [navigate]);
return null;
}
function EditorRoute() {
useEffect(() => {
if (!!state.game) state.stopGame();
if (state.game)
state.stopGame();
state.worldEditor.resetSelection();
}, []);
return null;
@ -45,8 +51,9 @@ function EditorRoute() {
function EditorObjectRoute() {
const { id } = useParams<{ id: string }>();
useEffect(() => {
if (!id) return;
if (!!state.game)
if (!id)
return;
if (state.game)
state.stopGame();
const editMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id === id
? state.worldEditor.selection.editMode ?? 'translate'
@ -59,8 +66,10 @@ function EditorObjectRoute() {
function EditorObjectTypeRoute() {
const { id } = useParams<{ id: string }>();
useEffect(() => {
if (!id) return;
if (!!state.game) state.stopGame();
if (!id)
return;
if (state.game)
state.stopGame();
const editMode = state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id === id
? state.worldEditor.selection.editMode ?? 'scripts'
: 'scripts';

View File

@ -7,7 +7,7 @@ Blockly.Blocks['console_log_action'] = {
.setAlign(Blockly.inputs.Align.RIGHT)
// .setCheck('String')
.appendField('print');
this.setInputsInline(false)
this.setInputsInline(false);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setTooltip('Print value to console');

View File

@ -12,7 +12,7 @@ Blockly.Blocks['physics_apply_impulse_action'] = {
.appendField(new Blockly.FieldNumber(1, -10, 10, 0.01), 'FORCE');
this.setInputsInline(true)
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setTooltip('Push me in a direction');

View File

@ -1,6 +1,7 @@
import * as Blockly from "blockly";
export class ObjecTypeField extends Blockly.Field {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(value: any, validator: any) {
super(value, validator);

View File

@ -5,7 +5,7 @@ Blockly.Blocks['current_object_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput('NAME')
.appendField('Me');
this.setInputsInline(false)
this.setInputsInline(false);
this.setOutput(true, 'Object');
this.setTooltip('Returns current object instance');
this.setColour(315);

View File

@ -5,7 +5,7 @@ Blockly.Blocks['current_object_type_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput('NAME')
.appendField('My type');
this.setInputsInline(false)
this.setInputsInline(false);
this.setOutput(true, 'ObjectType');
this.setTooltip('Returns current object type');
this.setColour(315);

View File

@ -6,7 +6,7 @@ Blockly.Blocks['object_by_id_value'] = {
this.appendEndRowInput()
.appendField('Object with id')
.appendField(new Blockly.FieldTextInput(''), 'TARGET_ID');
this.setInputsInline(false)
this.setInputsInline(false);
this.setOutput(true, 'Object');
this.setTooltip('Returns object by id, if any');
this.setColour(315);

View File

@ -5,7 +5,7 @@ Blockly.Blocks['player_object_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput()
.appendField('Player');
this.setInputsInline(false)
this.setInputsInline(false);
this.setOutput(true, 'Object');
this.setTooltip('Returns object that is controlled by player');
this.setColour(315);

View File

@ -46,7 +46,7 @@ export const PlayerObjectView = observer(function ({ object, objectType }: Playe
shoulderOffset: new Vector3(W * 0.1, centerY + H * 0.3, bb.max[2] + radius),
lookAtY: centerY,
};
}, [object.id]);
}, [object.cache.boundingBox]);
const yawRef = useRef(0);
const pitchRef = useRef(0);
@ -69,9 +69,11 @@ export const PlayerObjectView = observer(function ({ object, objectType }: Playe
useEffect(() => {
const canvas = gl.domElement;
const onClick = () => { if (document.pointerLockElement !== canvas) canvas.requestPointerLock()?.catch(() => {}); };
const onClick = () => { if (document.pointerLockElement !== canvas)
canvas.requestPointerLock()?.catch(() => {}); };
const onMouseMove = (e: MouseEvent) => {
if (document.pointerLockElement !== canvas) return;
if (document.pointerLockElement !== canvas)
return;
mouseRef.current.x += e.movementX;
mouseRef.current.y += e.movementY;
};
@ -85,12 +87,16 @@ export const PlayerObjectView = observer(function ({ object, objectType }: Playe
useBeforePhysicsStep((world) => {
const rb = handleRef.current?.rb;
if (!rb) return;
if (!rb)
return;
const collider = rb.collider(0);
if (!collider) return;
if (!collider)
return;
const controller = controllerRef.current;
if (!controller) return;
if (state.game?.isPaused) return;
if (!controller)
return;
if (state.game?.isPaused)
return;
const dt = world.timestep;
const yaw = yawRef.current;
@ -128,7 +134,8 @@ export const PlayerObjectView = observer(function ({ object, objectType }: Playe
useFrame(({ camera }, delta) => {
const rb = handleRef.current?.rb;
if (!rb) return;
if (!rb)
return;
mouseRef.current.x += joystickValues.look.x * LOOK_RATE * delta;
mouseRef.current.y += -joystickValues.look.y * LOOK_RATE * delta;

View File

@ -24,5 +24,5 @@ export const LeftPanel = observer(function () {
{/* <button onClick={handleLoadWorld}>Load</button> */}
<button onClick={handleLoadMockWorld}>Load mock world</button>
<div className="debug"><RenderInfoView /></div>
</Panel>
</Panel>;
});

View File

@ -26,5 +26,5 @@ export const MainPanel = observer(function () {
<div><span className="title">{objectType?.name}</span> ({objects.length} object{objects.length > 1 ? 's' : ''})</div>
</header>
<ScriptEditorView objectType={objectType} />
</Panel>
</Panel>;
});

View File

@ -36,7 +36,7 @@ const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: S
[ref],
);
useHelper(isSelected ? groupRef : { current: null } as any, BoxHelper, 'white');
useHelper(isSelected ? groupRef : { current: null }, BoxHelper, 'white');
if (!isSelected || selectionMode === undefined || !groupRef.current)
return null;

View File

@ -30,7 +30,8 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
useEffect(
() => {
if (props.isPlayer) return;
if (props.isPlayer)
return;
return reaction(
() => 'version' in object ? object.version : 0,
() => {
@ -53,19 +54,20 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
},
);
},
[object.id]
[object, object.id, props.isPlayer]
);
const gameObj = object as RuntimeGameObjectInstance;
useEffect(
() => {
const gameObj = object as RuntimeGameObjectInstance;
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 };
@ -76,7 +78,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
},
);
},
[object.id]
[gameObj.pendingActions, object.id]
);
function handleClick(e: ThreeEvent<MouseEvent>) {
@ -126,4 +128,4 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
</group>
</SyncRigidBody>
);
}
};

View File

@ -9,5 +9,5 @@ export type PanelProps = {
export const Panel = observer(function ({ children, side = 'left' }: PanelProps) {
return <div className={`panel ${side}`}>
{children}
</div >
</div >;
});

View File

@ -8,5 +8,5 @@ export const Panels = observer(function () {
return <div className="overlay-panels">
<LeftPanel />
<MainPanel />
</div>
</div>;
});

View File

@ -9,7 +9,8 @@ export const RenderInfoView = observer(function () {
useEffect(() => {
const el = containerRef.current;
if (!el) return;
if (!el)
return;
el.appendChild(chartRef.current);
return () => { el.removeChild(chartRef.current); };
}, []);
@ -25,5 +26,5 @@ export const RenderInfoView = observer(function () {
<span>textures: {info.textures}</span>
</div>
)}
</>
</>;
});

View File

@ -1,6 +1,5 @@
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Canvas } from '@react-three/fiber';
import { KeyboardControls, Stats } from '@react-three/drei';
import { action } from 'mobx';
import { chartRef } from './chartRef';
import { observer } from 'mobx-react-lite';
import { state } from '../state';
@ -9,23 +8,7 @@ import { SceneEditorView } from './SceneEditorView';
import { JoystickView } from './JoystickView';
import type { RefObject } from 'react';
import { IconPlayerPlayFilled, IconPlayerPauseFilled, IconPlayerStopFilled } from '@tabler/icons-react';
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;
}
import { RenderInfoUpdater } from './renderInfo';
export const ThreeView = observer(function () {
const isGame = !!state.game;
@ -63,5 +46,5 @@ export const ThreeView = observer(function () {
</div>
</div>
</KeyboardControls>
)
);
});

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;
}

View File

@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import { observer } from "mobx-react-lite";
import { runInAction } from "mobx";
import * as Blockly from "blockly";
@ -68,10 +68,11 @@ export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorV
const containerRef = useRef<HTMLDivElement>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const objectTypeRef = useRef(objectType);
objectTypeRef.current = objectType;
useLayoutEffect(() => { objectTypeRef.current = objectType; });
useEffect(() => {
if (!containerRef.current) return;
if (!containerRef.current)
return;
const workspace = Blockly.inject(containerRef.current, { toolbox: TOOLBOX, theme: gameTheme });
workspaceRef.current = workspace;
@ -116,11 +117,12 @@ export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorV
workspace.dispose();
workspaceRef.current = null;
};
}, []);
}, [objectType.program]);
useEffect(() => {
const workspace = workspaceRef.current;
if (!workspace) return;
if (!workspace)
return;
try {
Blockly.Events.disable();
@ -132,7 +134,7 @@ export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorV
} finally {
Blockly.Events.enable();
}
}, [objectType.id]);
}, [objectType.id, objectType.program]);
return <div className="script-editor">
<div ref={containerRef} className="blockly-container" />

View File

@ -32,7 +32,8 @@ export const VoxelEditorPage = observer(function () {
useEffect(() => {
return () => {
if (id) state.worldEditor.refreshObjectTypeCaches(id);
if (id)
state.worldEditor.refreshObjectTypeCaches(id);
};
}, [id]);

View File

@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App.tsx'
import './index.scss'
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import './index.scss';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
);

View File

@ -15,8 +15,10 @@ export class GameScheduler {
tick(deltaTime: number): void {
const next: Coroutine[] = [];
for (const co of this.coroutines) {
if (this.isReady(co)) this.step(co, next);
else next.push(this.countdown(co, deltaTime));
if (this.isReady(co))
this.step(co, next);
else
next.push(this.countdown(co, deltaTime));
}
this.coroutines = next;
}
@ -47,7 +49,8 @@ export class GameScheduler {
private step(co: Coroutine, list: Coroutine[]): void {
const result = co.gen.next();
if (!result.done) list.push(this.make(co.gen, result.value));
if (!result.done)
list.push(this.make(co.gen, result.value));
}
private make(gen: Generator<YieldCommand, void, void>, cmd: YieldCommand): Coroutine {

View File

@ -37,6 +37,6 @@ export const DEFAULT_VOXEL_TYPES = {
[dirt.id]: dirt,
[water.id]: water,
[glass.id]: glass,
}
};
export const DEFAULT_VOXEL_TYPE = stone;

View File

@ -1,9 +1,9 @@
import type { Game, RuntimeGameScene, RuntimeScene, World } from "../types";
import type { GameEventContext, GameEventHandler, InternalGameEventBus } from "../types/runtime/gameBus";
import type { GameEventContext, GameEventHandler, GeneratorEventHandler, InternalGameEventBus } from "../types/runtime/gameBus";
import { clone } from "../utils";
import { populateRuntimeScene } from "../utils/runtime";
import { ObjectApi } from "../utils/runtime/objectApi";
import type { GameScheduler, YieldCommand } from "./GameScheduler";
import type { GameScheduler, YieldCommand } from "./gameScheduler";
export class GameEventBus {
private handlers = new Map<string, GameEventHandler[]>();
@ -70,7 +70,7 @@ export class GameFactory {
scheduler: GameScheduler,
): void {
const handlerWrappers = new WeakMap<Function, (ctx: GameEventContext) => void>();
const handlerWrappers = new WeakMap<GeneratorEventHandler, GameEventHandler>();
const wait = (seconds: number): YieldCommand => ({ type: 'waitSeconds', seconds });
const waitFrames = (frames: number): YieldCommand => ({ type: 'waitFrames', frames });
@ -102,11 +102,11 @@ export class GameFactory {
.forEach((o) => {
const api = new ObjectApi(o, ot, world, scene);
gameScript({ ...internalBus, wait, waitFrames, waitUntil }, { api, object: o, objectType: ot });
})
});
});
}
catch (err) {
console.log('Error running game script:\n' + err)
console.log('Error running game script:\n' + err);
}
}
}

View File

@ -3,7 +3,7 @@ import type { WorldState } from "./worldState";
import type { Game, Pos3, RuntimeGameScene } from "../types";
import type { CameraProps } from "@react-three/fiber";
import { GameEventBus, GameFactory } from "../model/gameFactory";
import { GameScheduler } from "../model/GameScheduler";
import { GameScheduler } from "../model/gameScheduler";
import type { GameEventContext } from "../types/runtime/gameBus";
export class GameState {
@ -25,8 +25,7 @@ export class GameState {
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// @ts-ignore
// @ts-expect-error -- for future use
private withoutAutoSave(fn: () => void) {
this._stopAutoSave();
fn();
@ -72,7 +71,7 @@ export class GameState {
paused: toJS(this.isPaused),
time: toJS(this.time),
scene: toJS(this.scene),
}
};
}
public setGame(value: Game) {
@ -95,7 +94,7 @@ export class GameState {
position: cam.position,
fov: 50,
rotation: cam.look,
}
};
}
// public load() {

View File

@ -61,7 +61,7 @@ export class MenuState {
</>,
className: isPlayer ? 'player-controlled-object' : undefined,
actions,
onClick: () => { state.worldEditor.setSelectedObject({ id: o.id, editMode: 'translate' }) },
onClick: () => { state.worldEditor.setSelectedObject({ id: o.id, editMode: 'translate' }); },
selected: () => state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === o.id,
} as MenuNode;
});
@ -89,10 +89,10 @@ export class MenuState {
onClick: () => { editor.deleteObjectType(ot.id); },
},
],
onClick: () => { editor.setSelectedObjectType({ id: ot.id, editMode: 'scripts' }) },
onClick: () => { editor.setSelectedObjectType({ id: ot.id, editMode: 'scripts' }); },
selected: () => editor.selection?.type === 'objectType' && editor.selection?.id === ot.id,
children,
} as MenuNode
} as MenuNode;
});
}
@ -101,7 +101,7 @@ export class MenuState {
{
id: 'editor-scene-menu',
title: 'Scene',
onClick: () => { state.worldEditor.resetSelection() },
onClick: () => { state.worldEditor.resetSelection(); },
selected: () => !state.worldEditor.selection,
children: this.editorObjectTypesMenu,
actions: [{
@ -111,13 +111,13 @@ export class MenuState {
onClick: () => { state.worldEditor.addObjectType(); },
}],
}
]
];
}
public get nodes(): MenuNode[] {
return [
...this.editorMenu,
]
];
}
public nodeContainsSelected(node: MenuNode): boolean {

View File

@ -165,33 +165,39 @@ export class WorldEditorState {
public addVoxelToObjectType(typeId: string, voxel: Voxel): void {
const objectType = this.world.getObjectTypeById(typeId);
if (!objectType) return;
if (!objectType)
return;
const occupied = objectType.voxels.some(v =>
v.position[0] === voxel.position[0] &&
v.position[1] === voxel.position[1] &&
v.position[2] === voxel.position[2]
);
if (!occupied) objectType.voxels.push(voxel);
if (!occupied)
objectType.voxels.push(voxel);
}
public removeVoxelFromObjectType(typeId: string, position: V3): void {
const objectType = this.world.getObjectTypeById(typeId);
if (!objectType) return;
if (!objectType)
return;
const idx = objectType.voxels.findIndex(v =>
v.position[0] === position[0] &&
v.position[1] === position[1] &&
v.position[2] === position[2]
);
if (idx !== -1) objectType.voxels.splice(idx, 1);
if (idx !== -1)
objectType.voxels.splice(idx, 1);
}
public refreshObjectTypeCaches(typeId: string): void {
const objectType = this.world.getObjectTypeById(typeId);
if (!objectType) return;
if (!objectType)
return;
const world = this.world.data;
const now = Date.now();
for (const obj of Object.values(this.scene.objects)) {
if (obj.typeId !== typeId) continue;
if (obj.typeId !== typeId)
continue;
obj.cache.voxelGroups = getObjectVoxelGroups(objectType, world.voxelTypes);
obj.cache.colliderMesh = buildObjectTrimesh(objectType, world.voxelTypes);
obj.cache.boundingBox = computeBoundingBox(objectType);
@ -207,7 +213,7 @@ export class WorldEditorState {
if (this.scene.objects[id].typeId === typeId)
this.deleteObject(id);
delete (this.world.data.objectTypes[typeId])
delete (this.world.data.objectTypes[typeId]);
}
}

View File

@ -51,7 +51,7 @@ export class WorldState {
} as ObjectInstance));
const objectMap = Object.fromEntries(
objects.map((obj) => [obj.id, obj]),
) as Record<string, ObjectInstance>
) as Record<string, ObjectInstance>;
this.data = populateRuntimeWorld({
objectTypes: {
@ -106,7 +106,7 @@ export class WorldState {
private saveData(data: World): void {
console.log('Saving world...');
const stack = new Error('Saving world...').stack!.split('\n').slice(1);
const { voxelTypes, initialScene, ...debug } = toJS(data);
const { voxelTypes: _vts, initialScene: _is, ...debug } = toJS(data);
console.dir({ stack, debug });
WorldFactory.save(toJS(this.data));
}

View File

@ -15,5 +15,5 @@ export function v3toRapier(value: V3): Vector3Object {
x: value[0],
y: value[1],
z: value[2],
}
};
}

View File

@ -34,6 +34,6 @@ export function populateRuntimeObject(object: ObjectInstance, world: World): Run
}
export function depopulateRuntimeObject(object: RuntimeObjectInstance, _world: World): ObjectInstance {
const { cache, ...result } = object;
const { cache: _cache, ...result } = object;
return result;
}

View File

@ -24,7 +24,8 @@ export class ObjectApi {
const dy = targetPosition[1] - py;
const dz = targetPosition[2] - pz;
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len < 1e-6) return;
if (len < 1e-6)
return;
const direction: V3 = [dx / len, dy / len, dz / len];
this.object.pendingActions.impulse = { direction, amplitude };
}

View File

@ -5,12 +5,12 @@ export function populateRuntimeScene(scene: Scene, world: World): RuntimeScene {
return {
...scene,
objects: Object.fromEntries(Object.entries(scene.objects).map(([id, obj]) => [id, populateRuntimeObject(obj, world)])),
}
};
}
export function depopulateRuntimeScene(scene: RuntimeScene, world: World): Scene {
return {
...scene,
objects: Object.fromEntries(Object.entries(scene.objects).map(([id, obj]) => [id, depopulateRuntimeObject(obj, world)])),
}
};
}

View File

@ -1,7 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
});