actions reactored. multi-tick action support
This commit is contained in:
+15
-10
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+633
-112
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,6 @@
|
||||
packages:
|
||||
- 'webclient'
|
||||
- 'server'
|
||||
allowBuilds:
|
||||
'@parcel/watcher': false
|
||||
esbuild: true
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 { };
|
||||
|
||||
@@ -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 { };
|
||||
|
||||
@@ -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 { };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './3d';
|
||||
export * from './model';
|
||||
export * from './runtime';
|
||||
export * from './scripts';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './eventBus';
|
||||
export * from './actions';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user