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
+139
View File
@@ -0,0 +1,139 @@
import { makeAutoObservable, reaction, toJS } from "mobx";
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 type { GameEventContext } from "../types/runtime/gameBus";
export class GameState {
private readonly world: WorldState;
public isPaused: boolean = false;
public time: number = 0;
public isFirstTick: boolean;
public scene: RuntimeGameScene;
private eventBus: GameEventBus;
private scheduler: GameScheduler;
private startAutoSave() {
return reaction(
() => (this.asGame),
(data) => this.saveData(data),
{ delay: 5000 }
);
}
// @ts-expect-error -- for future use
private withoutAutoSave(fn: () => void) {
this._stopAutoSave();
fn();
this._stopAutoSave = this.startAutoSave();
}
private _stopAutoSave: () => void = () => { };
constructor(world: WorldState) {
this.world = world;
const rawWorld = toJS(this.world.data);
const game = GameFactory.create(rawWorld);
this.isPaused = game.paused;
this.time = game.time;
this.isFirstTick = true;
this.scene = game.scene;
const eventBus = new GameEventBus();
this.eventBus = eventBus;
this.scheduler = new GameScheduler();
this._stopAutoSave = this.startAutoSave();
makeAutoObservable(this);
GameFactory.evalGameScripts(rawWorld, this.scene, eventBus, this.scheduler);
}
public emitEvent(event: string, context: Omit<GameEventContext, 'world' | 'scene'> = {}) {
this.eventBus.emit(
event,
{
...context,
world: this.world.data,
scene: this.scene,
},
);
}
public get asGame(): Game {
return {
paused: toJS(this.isPaused),
time: toJS(this.time),
scene: toJS(this.scene),
};
}
public setGame(value: Game) {
this.isPaused = value.paused;
this.time = value.time;
this.scene = value.scene;
}
public setIsPaused(value: boolean) {
this.isPaused = value;
}
public get camera(): Pos3 {
return this.world.data.editorCamera;
}
public get cameraAsThree(): CameraProps {
const cam = this.camera;
return {
position: cam.position,
fov: 50,
rotation: cam.look,
};
}
// public load() {
// console.log('Loading game...');
// this.withoutAutoSave(() => {
// this.data = GameFactory.load() ?? GameFactory.create(this.world.data);
// });
// }
private saveData(data: Game): void {
console.log('Saving game...');
const stack = new Error('Saving game...').stack!.split('\n').slice(1);
const { ...debug } = toJS(data);
console.dir({ stack, debug });
GameFactory.save(data);
}
public save(): void {
this.saveData(this.asGame);
}
public resume(): void {
this.isPaused = false;
}
public pause(): void {
this.isPaused = true;
}
public tick(deltaTime: number): void {
if (this.isPaused)
return;
this.scheduler.tick(deltaTime);
this.time += deltaTime;
if (this.isFirstTick) {
this.isFirstTick = false;
this.emitEvent('game_start');
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './rootState';
+127
View File
@@ -0,0 +1,127 @@
import { makeAutoObservable } from "mobx";
import { state } from "./rootState";
import type { ReactNode } from "react";
import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff, IconEdit } from '@tabler/icons-react';
export type MenuNodeAction = {
id: string;
content: ReactNode;
tooltip: string;
onClick?: () => void;
link?: string;
}
export type MenuNode = {
id: string;
title: ReactNode;
className?: string;
actions?: MenuNodeAction[];
onClick?: () => void;
selected?: () => boolean;
children?: MenuNode[];
}
export class MenuState {
constructor() {
makeAutoObservable(this);
}
private get editorObjectTypesMenu(): MenuNode[] {
const editor = state.worldEditor;
const scene = editor.scene;
return Object.values(state.world.data.objectTypes)
.map((ot) => {
const children = Object.values(scene.objects)
.filter((o) => o.typeId === ot.id)
.map((o, idx) => {
const isPlayer = scene.playerObjectId === o.id;
const actions: MenuNodeAction[] = [];
if (!isPlayer) {
actions.push({
id: 'delete-object-instance',
content: <IconTrash size="1em" />,
tooltip: 'Delete the object',
onClick: () => { editor.deleteObject(o.id); },
});
actions.push({
id: 'control-object-instance-by-player',
content: <IconRun size="1em" />,
tooltip: 'Mark as player',
onClick: () => { scene.playerObjectId = o.id; },
});
}
return {
id: `o-${o.id}`,
title: <>
{isPlayer && <IconRun size="1em" />}
{`${ot.name} ${idx + 1}`}
</>,
className: isPlayer ? 'player-controlled-object' : undefined,
actions,
onClick: () => { state.worldEditor.setSelectedObject({ id: o.id, editMode: 'translate' }); },
selected: () => state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === o.id,
} as MenuNode;
});
return {
id: `ot-${ot.id}`,
title: `${ot.name} (${children.length ? children.length : 'no'} instance${children.length != 1 ? 's' : ''})`,
actions: [
{
id: 'create-object-instance',
content: <IconPlus size="1em" />,
tooltip: 'Create new object instance',
onClick: () => { editor.addObjectInstanceAtRandomPosition(ot.id); },
},
{
id: 'edit-object-type-voxels',
content: <IconEdit size="1em" />,
tooltip: 'Edit object type voxels',
link: `/editor/objectType/${ot.id}/voxels`,
},
{
id: 'delete-object-type',
content: <IconCubeOff size="1em" />,
tooltip: 'Delete object type',
onClick: () => { editor.deleteObjectType(ot.id); },
},
],
onClick: () => { editor.setSelectedObjectType({ id: ot.id, editMode: 'scripts' }); },
selected: () => editor.selection?.type === 'objectType' && editor.selection?.id === ot.id,
children,
} as MenuNode;
});
}
private get editorMenu(): MenuNode[] {
return [
{
id: 'editor-scene-menu',
title: 'Scene',
onClick: () => { state.worldEditor.resetSelection(); },
selected: () => !state.worldEditor.selection,
children: this.editorObjectTypesMenu,
actions: [{
id: 'create-object-type',
content: <IconCubePlus size="1em" />,
tooltip: 'Create new object type',
onClick: () => { state.worldEditor.addObjectType(); },
}],
}
];
}
public get nodes(): MenuNode[] {
return [
...this.editorMenu,
];
}
public nodeContainsSelected(node: MenuNode): boolean {
return !!(node.selected?.() || node.children?.some((child) => this.nodeContainsSelected(child)));
}
}
+64
View File
@@ -0,0 +1,64 @@
import { makeAutoObservable } from "mobx";
import { WorldState } from "./worldState";
import { WorldEditorState } from "./worldEditorState";
import { GameState } from "./gameState";
import { MenuState } from "./menuState";
import type { RuntimeObjectInstance } from "../types";
export type RenderInfo = {
calls: number,
triangles: number,
shaders: number,
geometries: number,
textures: number,
}
export class RootState {
public readonly world = new WorldState();
public readonly worldEditor: WorldEditorState;
public game: GameState | undefined;
public renderInfo: RenderInfo | undefined;
public readonly menu = new MenuState();
constructor() {
this.worldEditor = new WorldEditorState(this.world);
makeAutoObservable(
this,
{
world: false,
menu: false,
},
);
}
public startGame(): void {
state.worldEditor.resetSelection();
if (this.game)
this.stopGame();
this.game = new GameState(this.world);
}
public stopGame(): void {
if (!this.game)
return;
this.game.pause();
this.game = undefined;
}
public setRenderInfo(value: RenderInfo | undefined) {
this.renderInfo = value;
}
public markObjectAsPlayer(object: RuntimeObjectInstance) {
if (this.game)
this.game.scene.playerObjectId = object.id;
else
this.world.data.initialScene.playerObjectId = object.id;
}
}
export const state = new RootState();
state.world.load();
+219
View File
@@ -0,0 +1,219 @@
import { makeAutoObservable, runInAction } from "mobx";
import type { WorldState } from "./worldState";
import { type ObjectInstance, type Pos3, type R3, type V3, type RuntimeScene, type ObjectType, type Voxel } from "../types";
import { createObjectInstance } from "../utils/object";
import { randomId } from "../utils";
import { state } from "./rootState";
import { populateRuntimeObject, computeBoundingBox } from "../utils/runtime";
import { DEFAULT_VOXEL_TYPE } from "../model/defaultVoxelTypes";
import { getObjectVoxelGroups } from "../utils/graphics/voxelGroup";
import { buildObjectTrimesh } from "../utils/graphics/mesh";
export const ObjectEditModeEnum = [
'translate',
'rotate',
'scale',
] as const;
type ObjectEditModeTuple = typeof ObjectEditModeEnum;
export type ObjectEditMode = ObjectEditModeTuple[number];
export function nextObjectEditMode(mode: ObjectEditMode | undefined): ObjectEditMode {
if (mode === undefined)
return ObjectEditModeEnum[0];
const idx = ObjectEditModeEnum.indexOf(mode);
return ObjectEditModeEnum[(idx + 1) % ObjectEditModeEnum.length];
}
export const ObjectTypeEditModeEnum = [
'scripts',
] as const;
type ObjectTypeEditModeTuple = typeof ObjectTypeEditModeEnum;
export type ObjectTypeEditMode = ObjectTypeEditModeTuple[number];
export function nextObjectTypeEditMode(mode: ObjectTypeEditMode | undefined): ObjectTypeEditMode {
if (mode === undefined)
return ObjectTypeEditModeEnum[0];
const idx = ObjectTypeEditModeEnum.indexOf(mode);
return ObjectTypeEditModeEnum[(idx + 1) % ObjectTypeEditModeEnum.length];
}
export type SelectedObject = {
type: 'object',
id: string;
editMode: ObjectEditMode;
}
export type SelectedObjectType = {
type: 'objectType',
id: string;
editMode: ObjectTypeEditMode;
}
export type Selection = SelectedObject | SelectedObjectType;
export class WorldEditorState {
private readonly world: WorldState;
public selection: Selection | undefined;
constructor(world: WorldState) {
this.world = world;
makeAutoObservable(this);
}
public get isEnabled(): boolean {
return !state.game;
}
public setCamera(value: Pos3): void {
this.world.data.editorCamera = value;
}
public setSelection(value: Selection | undefined): void {
this.selection = value;
}
public setSelectedObject(value: Omit<SelectedObject, 'type'> | undefined): void {
this.setSelection(value
? {
type: 'object',
...value,
}
: undefined,
);
}
public setSelectedObjectType(value: Omit<SelectedObjectType, 'type'> | undefined): void {
this.setSelection(value
? {
type: 'objectType',
...value,
}
: undefined,
);
}
public resetSelection() {
this.setSelection(undefined);
}
public get scene(): RuntimeScene {
return this.world.data.initialScene;
}
public get camera(): Pos3 {
return this.world.data.editorCamera;
}
public setObjectTransform(id: string, position: V3, rotation: R3, scale: V3): void {
const obj = this.scene.objects[id];
if (!obj)
return;
runInAction(() => { // all in one go
obj.position = position;
obj.rotation = rotation;
obj.scale = scale;
});
}
public addObjectInstanceAtRandomPosition(typeId: string): ObjectInstance {
return this.addObjectInstance(
typeId,
{ x: Math.random() * 3, y: Math.random() * 3, z: Math.random() * 1 },
);
}
public addObjectInstance(
typeId: string,
pos: { x: number; y: number; z: number; },
): ObjectInstance {
const obj = populateRuntimeObject(createObjectInstance(randomId(), typeId), this.world.data);
obj.position = [pos.x, pos.y, pos.z];
obj.rotation = [0, 0, 0];
this.scene.objects[obj.id] = obj;
this.setSelectedObject({ id: obj.id, editMode: 'translate' });
return obj;
}
public deleteObject(objectId: string) {
if (this.selection?.type === 'object' && this.selection.id === objectId)
this.resetSelection();
delete (this.scene.objects[objectId]);
}
public addObjectType(): ObjectType {
const objectType: ObjectType = {
id: randomId(),
name: 'Unnamed',
voxels: [
{
typeId: DEFAULT_VOXEL_TYPE.id,
position: [0, 0, 0],
}
],
};
this.world.data.objectTypes[objectType.id] = objectType;
this.setSelectedObjectType({ id: objectType.id, editMode: 'scripts' });
return objectType;
}
public addVoxelToObjectType(typeId: string, voxel: Voxel): void {
const objectType = this.world.getObjectTypeById(typeId);
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);
}
public removeVoxelFromObjectType(typeId: string, position: V3): void {
const objectType = this.world.getObjectTypeById(typeId);
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);
}
public refreshObjectTypeCaches(typeId: string): void {
const objectType = this.world.getObjectTypeById(typeId);
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;
obj.cache.voxelGroups = getObjectVoxelGroups(objectType, world.voxelTypes);
obj.cache.colliderMesh = buildObjectTrimesh(objectType, world.voxelTypes);
obj.cache.boundingBox = computeBoundingBox(objectType);
obj.cache.updatedAt = now;
}
}
public deleteObjectType(typeId: string) {
if (this.selection?.type === 'objectType' && this.selection.id === typeId)
this.resetSelection();
for (const id in this.scene.objects)
if (this.scene.objects[id].typeId === typeId)
this.deleteObject(id);
delete (this.world.data.objectTypes[typeId]);
}
}
+125
View File
@@ -0,0 +1,125 @@
import { makeAutoObservable, reaction, toJS } from "mobx";
import { WorldFactory } from "../model/worldFactory";
import { type ObjectInstance, type ObjectType, type World, type VoxelType, type RuntimeWorld } from "../types";
import { state } from "./rootState";
import { DEFAULT_VOXEL_TYPES } from "../model/defaultVoxelTypes";
import { wolf } from "../model/objectPrefabs/wolf";
import { terrainXZ } from "../model/objectPrefabs/terrain";
import { populateRuntimeWorld } from "../utils/runtime";
export class WorldState {
public data: RuntimeWorld = WorldFactory.create();
private startAutoSave() {
return reaction(() => toJS(this.data), (data) => this.saveData(data), { delay: 500 });
}
private withoutAutoSave(fn: () => void) {
this._stopAutoSave();
fn();
this._stopAutoSave = this.startAutoSave();
}
private _stopAutoSave: () => void = () => { };
constructor() {
makeAutoObservable(this);
this._stopAutoSave = this.startAutoSave();
}
public reset() {
console.log('Resetting world...');
this.withoutAutoSave(() => {
this.data = WorldFactory.create();
});
state.worldEditor.resetSelection();
}
public loadMock() {
console.log('Mocking world...');
const objects = Array(3).fill(0)
.map((_, idx) => ({
id: `obj${idx}`,
typeId: 'wolf',
physics: true,
gravityScale: 1,
position: [idx * 10 - 10, 5, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
} as ObjectInstance));
const objectMap = Object.fromEntries(
objects.map((obj) => [obj.id, obj]),
) as Record<string, ObjectInstance>;
this.data = populateRuntimeWorld({
objectTypes: {
wolf: {
id: 'wolf',
name: 'Wolf',
voxels: wolf,
},
terrain: {
id: 'terrain',
name: 'Terrain',
voxels: terrainXZ(50, 50),
}
},
voxelTypes: DEFAULT_VOXEL_TYPES,
editorCamera: {
position: [14, 17, -25],
look: [-2.5, 0.31, 3],
},
initialScene: {
objects: {
terrain: {
id: 'terrain',
typeId: 'terrain',
physics: false, // pinned
gravityScale: 0, // pinned
position: [0, -1, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
...objectMap,
},
playerObjectId: 'obj1',
},
gameRules: {
gravity: true,
},
});
console.log(objects);
state.worldEditor.resetSelection();
}
public load() {
console.log('Loading world...');
this.withoutAutoSave(() => {
this.data = WorldFactory.load() ?? WorldFactory.create();
});
state.worldEditor.resetSelection();
}
private saveData(data: World): void {
console.log('Saving world...');
const stack = new Error('Saving world...').stack!.split('\n').slice(1);
const { voxelTypes: _vts, initialScene: _is, ...debug } = toJS(data);
console.dir({ stack, debug });
WorldFactory.save(toJS(this.data));
}
public save(): void {
this.saveData(this.data);
}
public getObjectTypeById(id: string): ObjectType | undefined {
return this.data.objectTypes[id];
}
public getVoxelTypeById(id: string): VoxelType | undefined {
return this.data.voxelTypes[id];
}
}