actions reactored. multi-tick action support

This commit is contained in:
azykov@mail.ru
2026-06-12 11:46:42 +03:00
parent 36f76efdd7
commit 20a4edad5a
35 changed files with 1047 additions and 373 deletions
+15 -10
View File
@@ -1,19 +1,17 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['**/dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
@@ -27,4 +25,11 @@ export default defineConfig([
}],
},
},
])
{
files: ['webclient/**/*.{ts,tsx}'],
extends: [
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
},
]);
+5 -28
View File
@@ -4,43 +4,20 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"dev": "pnpm --parallel -r dev",
"build": "pnpm --parallel -r build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@react-three/rapier": "^2.2.0",
"@tabler/icons-react": "^3.44.0",
"@types/three": "^0.184.1",
"blockly": "^12.5.1",
"install": "^0.13.0",
"mobx": "^6.15.4",
"mobx-react-lite": "^4.1.1",
"mobx-utils": "^6.1.1",
"nipplejs": "^1.0.4",
"npm": "^11.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.16.0",
"three": "^0.184.0",
"uuid": "^14.0.0"
"client:dev": "pnpm --filter webclient dev",
"server:dev": "pnpm --filter server dev"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"sass": "^1.100.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
"typescript-eslint": "^8.59.2"
}
}
+633 -112
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1,2 +1,6 @@
packages:
- 'webclient'
- 'server'
allowBuilds:
'@parcel/watcher': false
esbuild: true
+17
View File
@@ -0,0 +1,17 @@
{
"name": "server",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -b",
"start": "node dist/index.js"
},
"dependencies": {
"fastify": "^5.3.2"
},
"devDependencies": {
"tsx": "^4.19.4"
}
}
+9
View File
@@ -0,0 +1,9 @@
import Fastify from 'fastify';
const fastify = Fastify({ logger: true });
fastify.get('/', async (_request, _reply) => {
return { status: 'ok' };
});
await fastify.listen({ port: 3000, host: '0.0.0.0' });
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "webclient",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@react-three/rapier": "^2.2.0",
"@tabler/icons-react": "^3.44.0",
"@types/three": "^0.184.1",
"blockly": "^12.5.1",
"mobx": "^6.15.4",
"mobx-react-lite": "^4.1.1",
"mobx-utils": "^6.1.1",
"nipplejs": "^1.0.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.16.0",
"three": "^0.184.0",
"uuid": "^14.0.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"sass": "^1.100.0",
"vite": "^8.0.12"
}
}
@@ -23,7 +23,7 @@ Blockly.Blocks['physics_apply_impulse_action'] = {
javascriptGenerator.forBlock['physics_apply_impulse_action'] = function (block, generator) {
const positionValue = generator.valueToCode(block, 'POSITION', Order.ATOMIC);
const forceValue = block.getFieldValue('FORCE');
return `api.applyImpulse(${positionValue}, ${forceValue});\n`;
return `yield helpers.actions.ApplyImpulse(${positionValue}, ${forceValue});\n`;
};
export { };
+1 -1
View File
@@ -16,7 +16,7 @@ Blockly.Blocks['wait_seconds_action'] = {
javascriptGenerator.forBlock['wait_seconds_action'] = function (block) {
const seconds = block.getFieldValue('SECONDS');
return `yield wait(${seconds});\n`;
return `yield helpers.actions.WaitSeconds(${seconds});\n`;
};
export { };
+6 -2
View File
@@ -21,12 +21,16 @@ export function generateCode(workspace: Blockly.WorkspaceSvg): string {
//TODO use args
result += `onGameEvent(
result += `eventBus.on(
'${block.type.replace(/_event$/, '')}',
function*(context) {
if (context.object && (context.object.id !== object.id))
if (context.object && (context.object.id !== thisObject.id))
return;
${body} },
{
thisObject,
thisObjectType,
},
);
`;
}
@@ -13,7 +13,7 @@ Blockly.Blocks['current_object_value'] = {
};
javascriptGenerator.forBlock['current_object_value'] = function (_block, _generator) {
return ['object', Order.NONE];
return ['thisObject', Order.NONE];
};
export { };
@@ -13,7 +13,7 @@ Blockly.Blocks['current_object_type_value'] = {
};
javascriptGenerator.forBlock['current_object_type_value'] = function (_block, _generator) {
return ['objectType', Order.NONE];
return ['thisObjectType', Order.NONE];
};
export { };
+4 -3
View File
@@ -14,7 +14,7 @@ export function getObjectTypeDropDownOptions(): [string, string][] {
Blockly.Blocks['object_type_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput('NAME')
this.appendEndRowInput()
.appendField(
new Blockly.FieldDropdown(getObjectTypeDropDownOptions),
'OBJECT_TYPE',
@@ -26,8 +26,9 @@ Blockly.Blocks['object_type_value'] = {
}
};
javascriptGenerator.forBlock['current_object_type_value'] = function (_block, _generator) {
return ['objectType', Order.NONE];
javascriptGenerator.forBlock['object_type_value'] = function (block, _generator) {
const objectTypeValue = block.getFieldValue('OBJECT_TYPE');
return [`world.objectTypes[${objectTypeValue}]`, Order.NONE];
};
export { };
@@ -61,24 +61,11 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
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;
});
},
);
runInAction(() => { gameObj.rb = () => rbRef.current; });
return () => { runInAction(() => { gameObj.rb = () => null; }); };
},
[gameObj.pendingActions, object.id]
// eslint-disable-next-line react-hooks/exhaustive-deps
[object.id],
);
function handleClick(e: ThreeEvent<MouseEvent>) {
@@ -85,6 +85,9 @@ export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorV
const current = objectTypeRef.current;
const serialized = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
current.program = serialized;
console.log(serialized);
try {
current.javascript = generateCode(workspace);
}
+57 -65
View File
@@ -1,39 +1,18 @@
import type { Game, RuntimeGameScene, RuntimeScene, World } from "../types";
import type { GameEventContext, GameEventHandler, GeneratorEventHandler, InternalGameEventBus } from "../types/runtime/gameBus";
import { toJS } from "mobx";
import type { Game, RuntimeGameObjectInstance, RuntimeGameScene, RuntimeScene, ScriptAction, ScriptCoroutineContext, World } from "../types";
import { clone } from "../utils";
import { populateRuntimeScene } from "../utils/runtime";
import { ObjectApi } from "../utils/runtime/objectApi";
import type { GameScheduler, YieldCommand } from "./gameScheduler";
import { ScriptGameEventBus } from "../scripts/eventBus";
import * as ScriptActions from "../scripts/actions";
import type { ScriptCoroutines } from "../scripts/coroutines";
export class GameEventBus {
private handlers = new Map<string, GameEventHandler[]>();
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// type ScriptActionHelperFunction<T extends abstract new (...args: any[]) => ScriptAction> =
// (...args: ConstructorParameters<T>) => InstanceType<T>;
on(event: string, handler: GameEventHandler): void {
console.log('subscribing to ' + event, handler);
let set = this.handlers.get(event);
if (!set) {
set = [];
this.handlers.set(event, set);
}
if (!set.includes(handler))
set.push(handler);
}
off(event: string, handler: GameEventHandler): void {
console.log('unubscribing from ' + event, handler);
const handlers = this.handlers.get(event);
if (!handlers)
return;
this.handlers.set(event, handlers.filter((h) => h !== handler));
}
emit(event: string, context: GameEventContext): void {
console.log('emitting ' + event);
this.handlers.get(event)
?.forEach((handler) => handler(context));
}
}
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// const scriptActionHelperFunction = <T extends new (...args: any[]) => ScriptAction>(Cls: T): ScriptActionHelperFunction<T> =>
// (...args) => new Cls(...args) as InstanceType<T>;
export class GameFactory {
@@ -66,47 +45,60 @@ export class GameFactory {
public static evalGameScripts(
world: World,
scene: RuntimeGameScene,
eventBus: GameEventBus,
scheduler: GameScheduler,
): void {
coroutines: ScriptCoroutines,
): ScriptGameEventBus {
const handlerWrappers = new WeakMap<GeneratorEventHandler, GameEventHandler>();
const eventBus = new ScriptGameEventBus(coroutines);
const wait = (seconds: number): YieldCommand => ({ type: 'waitSeconds', seconds });
const waitFrames = (frames: number): YieldCommand => ({ type: 'waitFrames', frames });
const waitUntil = (condition: () => boolean): YieldCommand => ({ type: 'waitUntil', condition });
const internalBus: InternalGameEventBus = {
onGameEvent(event, genFn) {
const wrapper: GameEventHandler = (ctx) => scheduler.startCoroutine(genFn(ctx) as Generator<YieldCommand, void, void>);
handlerWrappers.set(genFn, wrapper);
eventBus.on(event, wrapper);
},
offGameEvent(event, genFn) {
const wrapper = handlerWrappers.get(genFn);
if (wrapper) {
eventBus.off(event, wrapper);
handlerWrappers.delete(genFn);
}
},
const scriptHelpers = {
// convert all ScriptActions imported to a structure factory(coroutine):
// {
// WaitSeconds: (seconds) => new WaitSecondsScriptAction(coroutine, seconds),
// ...
// }
actions: (context: ScriptCoroutineContext) => Object.fromEntries(
Object.entries(ScriptActions)
.map(([name, Cls]) => [
name.replace(/ScriptAction$/, ''),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(...args: any[]) => new (Cls as any)(context, ...args),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as Record<string, (...args: any[]) => ScriptAction>,
};
try {
Object.values(world.objectTypes)
.filter((ot) => ot.javascript)
.forEach((ot) => {
const gameScript = eval(`(function gameScript({onGameEvent, offGameEvent, wait, waitFrames, waitUntil}, {api, object, objectType}) {${ot.javascript}})`);
Object.values(world.objectTypes)
.filter((ot) => ot.javascript)
.forEach((ot) => {
try {
const javascript = `(function gameScript(eventBus, helpers, thisObject, thisObjectType) {${ot.javascript}})`;
const gameScript = eval(javascript);
Object.values(scene.objects)
.filter((o) => o.typeId == ot.id)
.map((liveObj) => {
const snapshot = toJS(liveObj) as RuntimeGameObjectInstance;
snapshot.rb = () => liveObj.rb();
return snapshot;
})
.forEach((o) => {
const api = new ObjectApi(o, ot, world, scene);
gameScript({ ...internalBus, wait, waitFrames, waitUntil }, { api, object: o, objectType: ot });
try {
const localScriptHelpers = {
...scriptHelpers,
actions: scriptHelpers.actions({ thisObject: o, thisObjectType: ot }),
};
gameScript(eventBus, localScriptHelpers, o, ot);
}
catch (err) {
console.log('Error running object instance script:\n' + err);
}
});
});
}
catch (err) {
console.log('Error running game script:\n' + err);
}
}
catch (err) {
console.log('Error evaluating object type script:\n' + err);
}
});
return eventBus;
}
}
-66
View File
@@ -1,66 +0,0 @@
export type YieldCommand =
| { type: 'waitSeconds'; seconds: number }
| { type: 'waitFrames'; frames: number }
| { type: 'waitUntil'; condition: () => boolean };
type Coroutine =
& { gen: Generator<YieldCommand, void, void> }
& (| { wakeType: 'time'; remaining: number }
| { wakeType: 'frames'; remaining: number }
| { wakeType: 'condition'; test: () => boolean });
export class GameScheduler {
private coroutines: Coroutine[] = [];
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));
}
this.coroutines = next;
}
startCoroutine(gen: Generator<YieldCommand, void, void>): void {
const result = gen.next();
if (!result.done)
this.coroutines.push(this.make(gen, result.value));
}
reset(): void { this.coroutines = []; }
private isReady(co: Coroutine): boolean {
switch (co.wakeType) {
case 'time': return co.remaining <= 0;
case 'frames': return co.remaining <= 0;
case 'condition': return co.test();
}
}
private countdown(co: Coroutine, deltaTime: number): Coroutine {
switch (co.wakeType) {
case 'time': return { ...co, remaining: co.remaining - deltaTime };
case 'frames': return { ...co, remaining: co.remaining - 1 };
default: return co;
}
}
private step(co: Coroutine, list: Coroutine[]): void {
const result = co.gen.next();
if (!result.done)
list.push(this.make(co.gen, result.value));
}
private make(gen: Generator<YieldCommand, void, void>, cmd: YieldCommand): Coroutine {
switch (cmd.type) {
case 'waitSeconds':
return { gen, wakeType: 'time', remaining: cmd.seconds };
case 'waitFrames':
return { gen, wakeType: 'frames', remaining: cmd.frames };
case 'waitUntil':
return { gen, wakeType: 'condition', test: cmd.condition };
}
}
}
@@ -0,0 +1,32 @@
import { ScriptAction, type ScriptCoroutineContext, type V3 } from "../../types";
export class ApplyImpulseScriptAction extends ScriptAction {
private targetPosition: V3;
private amplitude: number;
constructor(coroutine: ScriptCoroutineContext, targetPosition: V3, amplitude: number) {
super(coroutine);
this.targetPosition = targetPosition;
this.amplitude = amplitude;
}
public tick(_deltaTime: number): boolean {
const rb = this.coroutine.thisObject.rb();
if (!rb)
return true;
const { x: px, y: py, z: pz } = rb.translation();
const dx = this.targetPosition[0] - px;
const dy = this.targetPosition[1] - py;
const dz = this.targetPosition[2] - pz;
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len > 1e-6) {
const scale = (this.amplitude * 100) / len;
rb.applyImpulse({ x: dx * scale, y: dy * scale, z: dz * scale }, true);
}
return true;
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './waitConditionAction';
export * from './waitFramesAction';
export * from './waitSecondsAction';
export * from './applyImpulseAction';
@@ -0,0 +1,15 @@
import { ScriptAction, type ScriptCoroutineContext } from "../../types";
export class WaitConditionScriptAction extends ScriptAction {
private condition: () => boolean;
constructor(coroutine: ScriptCoroutineContext, condition: () => boolean) {
super(coroutine);
this.condition = condition;
}
public tick(_deltaTime: number): boolean {
return this.condition();
}
}
@@ -0,0 +1,16 @@
import { ScriptAction, type ScriptCoroutineContext } from "../../types";
export class WaitFramesScriptAction extends ScriptAction {
private remaining: number;
constructor(coroutine: ScriptCoroutineContext, frames: number) {
super(coroutine);
this.remaining = frames;
}
public tick(_deltaTime: number): boolean {
this.remaining--;
return this.remaining <= 0;
}
}
@@ -0,0 +1,16 @@
import { ScriptAction, type ScriptCoroutineContext } from "../../types";
export class WaitSecondsScriptAction extends ScriptAction {
private remaining: number;
constructor(coroutine: ScriptCoroutineContext, seconds: number) {
super(coroutine);
this.remaining = seconds;
}
public tick(deltaTime: number): boolean {
this.remaining -= deltaTime; // TODO should only consume as much time as needed and pass the remainder to next actions
return this.remaining <= 0;
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { ScriptAction, ScriptCoroutine } from "../types";
export class ScriptCoroutines {
private items: { coroutine: ScriptCoroutine, currentAction?: ScriptAction }[] = [];
public push(coroutines: ScriptCoroutine[]) {
this.items.push(
...coroutines
.map((c) => ({ coroutine: c })),
);
}
public tick(deltaTime: number): void {
// execute coroutines one by one and remove the ones that have finished
this.items = this.items
.filter((c) => !this.tickItem(c, deltaTime));
}
private tickItem(item: { coroutine: ScriptCoroutine, currentAction?: ScriptAction }, deltaTime: number): boolean {
if (!item.currentAction) {
try {
const yieldResult = item.coroutine.next();
if (yieldResult.done)
return true; // coroutine exhausted
item.currentAction = yieldResult.value;
}
catch (err) {
console.error(err);
return true; // coroutine failed to yield an action or return
}
}
try {
const done = item.currentAction.tick(deltaTime);
if (done)
item.currentAction = undefined;
}
catch (err) {
console.error(err);
item.currentAction = undefined;
}
return false;
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { ScriptContext, ScriptCoroutineContext, ScriptGameEventHandler } from "../types";
import type { ScriptCoroutines } from "./coroutines";
export type ScriptCoroutineGameEventHandler = {
handler: ScriptGameEventHandler;
coroutine: ScriptCoroutineContext;
}
export class ScriptGameEventBus {
private coroutines: ScriptCoroutines;
private handlers: Record<string, ScriptCoroutineGameEventHandler[]> = {};
constructor(coroutines: ScriptCoroutines) {
this.coroutines = coroutines;
}
on(event: string, handler: ScriptGameEventHandler, coroutine: ScriptCoroutineContext): void {
console.log('subscribing to ' + event, handler, coroutine);
let set = this.handlers[event];
if (!set) {
set = [];
this.handlers[event] = set;
}
if (!set.some((h) => h.handler === handler))
set.push({ handler, coroutine });
}
off(event: string, handler: ScriptGameEventHandler): void {
console.log('unubscribing from ' + event, handler);
const handlers = this.handlers[event];
if (!handlers)
return;
this.handlers[event] = handlers.filter((h) => h.handler !== handler);
}
emit(event: string, context: Omit<ScriptContext, keyof ScriptCoroutineContext>): void {
console.log('emitting ' + event);
const coroutines = this.handlers[event]
?.map((h) => {
console.log(`passing ${event} to ${h.handler}(${JSON.stringify(context)}, ${JSON.stringify(h.coroutine)})`);
return h.handler({ ...context, ...h.coroutine });
});
console.log(coroutines);
if (coroutines)
this.coroutines.push(coroutines);
}
}
+10 -13
View File
@@ -1,10 +1,10 @@
import { makeAutoObservable, reaction, toJS } from "mobx";
import type { WorldState } from "./worldState";
import type { Game, Pos3, RuntimeGameScene } from "../types";
import type { Game, Pos3, RuntimeGameScene, ScriptContext, ScriptCoroutineContext } 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";
import type { ScriptGameEventBus } from "../scripts/eventBus";
import { GameFactory } from "../model/gameFactory";
import { ScriptCoroutines } from "../scripts/coroutines";
export class GameState {
private readonly world: WorldState;
@@ -14,8 +14,8 @@ export class GameState {
public isFirstTick: boolean;
public scene: RuntimeGameScene;
private eventBus: GameEventBus;
private scheduler: GameScheduler;
private coroutines = new ScriptCoroutines();
private eventBus: ScriptGameEventBus;
private startAutoSave() {
return reaction(
@@ -45,17 +45,13 @@ export class GameState {
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);
this.eventBus = GameFactory.evalGameScripts(rawWorld, this.scene, this.coroutines);
}
public emitEvent(event: string, context: Omit<GameEventContext, 'world' | 'scene'> = {}) {
public emitEvent(event: string, context: Omit<ScriptContext, 'world' | 'scene' | keyof ScriptCoroutineContext> = {}) {
this.eventBus.emit(
event,
{
@@ -128,7 +124,8 @@ export class GameState {
if (this.isPaused)
return;
this.scheduler.tick(deltaTime);
this.coroutines.tick(deltaTime);
this.time += deltaTime;
if (this.isFirstTick) {
+1
View File
@@ -1,3 +1,4 @@
export * from './3d';
export * from './model';
export * from './runtime';
export * from './scripts';
+8 -3
View File
@@ -2,6 +2,13 @@ import type { V3 } from "../3d";
import type { VoxelGroup } from "../runtime";
import type { ObjectInstance } from "./object";
export type RigidBodyHandle = {
applyImpulse(impulse: { x: number; y: number; z: number }, wakeUp: boolean): void;
translation(): { x: number; y: number; z: number };
linvel(): { x: number; y: number; z: number };
angvel(): { x: number; y: number; z: number };
};
export type ObjectInstanceRuntimeData = {
cache: {
voxelGroups: VoxelGroup[];
@@ -9,9 +16,7 @@ export type ObjectInstanceRuntimeData = {
boundingBox: { min: V3; max: V3 };
updatedAt: number;
};
pendingActions: {
impulse?: { direction: V3, amplitude: number };
}
rb: () => RigidBodyHandle | null;
}
export type Runtime<T extends ObjectInstance> = T & ObjectInstanceRuntimeData;
-16
View File
@@ -1,16 +0,0 @@
import type { RuntimeGameObjectInstance, Scene, World } from "../model";
export type GameEventContext = {
world: World;
scene: Scene;
object?: RuntimeGameObjectInstance;
}
export type GameEventHandler = (context: GameEventContext) => void;
export type GeneratorEventHandler = (context: GameEventContext) => Generator<unknown, void, void>;
export type InternalGameEventBus = {
onGameEvent: (event: string, handler: GeneratorEventHandler) => void;
offGameEvent: (event: string, handler: GeneratorEventHandler) => void;
}
@@ -0,0 +1,12 @@
import type { ScriptCoroutineContext } from "../eventBus";
export abstract class ScriptAction {
protected coroutine: ScriptCoroutineContext;
constructor(coroutine: ScriptCoroutineContext) {
this.coroutine = coroutine;
}
public abstract tick(deltaTime: number): boolean; // true means action has completed
}
+17
View File
@@ -0,0 +1,17 @@
import type { ObjectType, RuntimeGameObjectInstance, Scene, World } from "../model";
import type { ScriptAction } from "./actions";
export type ScriptCoroutine = Generator<ScriptAction, void, void>;
export type ScriptCoroutineContext = {
thisObject: RuntimeGameObjectInstance,
thisObjectType: ObjectType;
};
export type ScriptContext = ScriptCoroutineContext & {
world: World;
scene: Scene;
object?: RuntimeGameObjectInstance;
}
export type ScriptGameEventHandler = (context: ScriptContext) => ScriptCoroutine;
+2
View File
@@ -0,0 +1,2 @@
export * from './eventBus';
export * from './actions';
+2 -2
View File
@@ -29,11 +29,11 @@ export function populateRuntimeObject(object: ObjectInstance, world: World): Run
boundingBox: computeBoundingBox(objectType),
updatedAt: 0,
},
pendingActions: {},
rb: () => null,
};
}
export function depopulateRuntimeObject(object: RuntimeObjectInstance, _world: World): ObjectInstance {
const { cache: _cache, ...result } = object;
const { cache: _cache, rb: _rb, ...result } = object;
return result;
}
-32
View File
@@ -1,32 +0,0 @@
import type { ObjectType, RuntimeGameObjectInstance, RuntimeGameScene, V3, World } from "../../types";
export class ObjectApi {
private object: RuntimeGameObjectInstance;
private obectType: ObjectType;
private world: World;
private scene: RuntimeGameScene;
constructor(
object: RuntimeGameObjectInstance,
obectType: ObjectType,
world: World,
scene: RuntimeGameScene,
) {
this.object = object;
this.obectType = obectType;
this.world = world;
this.scene = scene;
}
public applyImpulse(targetPosition: V3, amplitude: number) {
const [px, py, pz] = this.object.position;
const dx = targetPosition[0] - px;
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;
const direction: V3 = [dx / len, dy / len, dz / len];
this.object.pendingActions.impulse = { direction, amplitude };
}
}
+4
View File
@@ -4,4 +4,8 @@ import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 8888,
}
});