actions reactored. multi-tick action support
This commit is contained in:
+15
-10
@@ -1,19 +1,17 @@
|
|||||||
import js from '@eslint/js'
|
import js from '@eslint/js';
|
||||||
import globals from 'globals'
|
import globals from 'globals';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint';
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['**/dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.browser,
|
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",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "pnpm --parallel -r dev",
|
||||||
"build": "tsc -b && vite build",
|
"build": "pnpm --parallel -r build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"client:dev": "pnpm --filter webclient dev",
|
||||||
},
|
"server:dev": "pnpm --filter server dev"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^24.12.3",
|
"@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": "^10.3.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"sass": "^1.100.0",
|
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2"
|
||||||
"vite": "^8.0.12"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+633
-112
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- 'webclient'
|
||||||
|
- 'server'
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@parcel/watcher': false
|
'@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) {
|
javascriptGenerator.forBlock['physics_apply_impulse_action'] = function (block, generator) {
|
||||||
const positionValue = generator.valueToCode(block, 'POSITION', Order.ATOMIC);
|
const positionValue = generator.valueToCode(block, 'POSITION', Order.ATOMIC);
|
||||||
const forceValue = block.getFieldValue('FORCE');
|
const forceValue = block.getFieldValue('FORCE');
|
||||||
return `api.applyImpulse(${positionValue}, ${forceValue});\n`;
|
return `yield helpers.actions.ApplyImpulse(${positionValue}, ${forceValue});\n`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { };
|
export { };
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Blockly.Blocks['wait_seconds_action'] = {
|
|||||||
|
|
||||||
javascriptGenerator.forBlock['wait_seconds_action'] = function (block) {
|
javascriptGenerator.forBlock['wait_seconds_action'] = function (block) {
|
||||||
const seconds = block.getFieldValue('SECONDS');
|
const seconds = block.getFieldValue('SECONDS');
|
||||||
return `yield wait(${seconds});\n`;
|
return `yield helpers.actions.WaitSeconds(${seconds});\n`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { };
|
export { };
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ export function generateCode(workspace: Blockly.WorkspaceSvg): string {
|
|||||||
|
|
||||||
//TODO use args
|
//TODO use args
|
||||||
|
|
||||||
result += `onGameEvent(
|
result += `eventBus.on(
|
||||||
'${block.type.replace(/_event$/, '')}',
|
'${block.type.replace(/_event$/, '')}',
|
||||||
function*(context) {
|
function*(context) {
|
||||||
if (context.object && (context.object.id !== object.id))
|
if (context.object && (context.object.id !== thisObject.id))
|
||||||
return;
|
return;
|
||||||
${body} },
|
${body} },
|
||||||
|
{
|
||||||
|
thisObject,
|
||||||
|
thisObjectType,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Blockly.Blocks['current_object_value'] = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
javascriptGenerator.forBlock['current_object_value'] = function (_block, _generator) {
|
javascriptGenerator.forBlock['current_object_value'] = function (_block, _generator) {
|
||||||
return ['object', Order.NONE];
|
return ['thisObject', Order.NONE];
|
||||||
};
|
};
|
||||||
|
|
||||||
export { };
|
export { };
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Blockly.Blocks['current_object_type_value'] = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
javascriptGenerator.forBlock['current_object_type_value'] = function (_block, _generator) {
|
javascriptGenerator.forBlock['current_object_type_value'] = function (_block, _generator) {
|
||||||
return ['objectType', Order.NONE];
|
return ['thisObjectType', Order.NONE];
|
||||||
};
|
};
|
||||||
|
|
||||||
export { };
|
export { };
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function getObjectTypeDropDownOptions(): [string, string][] {
|
|||||||
|
|
||||||
Blockly.Blocks['object_type_value'] = {
|
Blockly.Blocks['object_type_value'] = {
|
||||||
init(this: Blockly.Block) {
|
init(this: Blockly.Block) {
|
||||||
this.appendEndRowInput('NAME')
|
this.appendEndRowInput()
|
||||||
.appendField(
|
.appendField(
|
||||||
new Blockly.FieldDropdown(getObjectTypeDropDownOptions),
|
new Blockly.FieldDropdown(getObjectTypeDropDownOptions),
|
||||||
'OBJECT_TYPE',
|
'OBJECT_TYPE',
|
||||||
@@ -26,8 +26,9 @@ Blockly.Blocks['object_type_value'] = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
javascriptGenerator.forBlock['current_object_type_value'] = function (_block, _generator) {
|
javascriptGenerator.forBlock['object_type_value'] = function (block, _generator) {
|
||||||
return ['objectType', Order.NONE];
|
const objectTypeValue = block.getFieldValue('OBJECT_TYPE');
|
||||||
|
return [`world.objectTypes[${objectTypeValue}]`, Order.NONE];
|
||||||
};
|
};
|
||||||
|
|
||||||
export { };
|
export { };
|
||||||
|
|||||||
@@ -61,24 +61,11 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
|
|||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
return reaction(
|
runInAction(() => { gameObj.rb = () => rbRef.current; });
|
||||||
() => gameObj.pendingActions.impulse,
|
return () => { runInAction(() => { gameObj.rb = () => null; }); };
|
||||||
(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;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
},
|
[object.id],
|
||||||
[gameObj.pendingActions, object.id]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleClick(e: ThreeEvent<MouseEvent>) {
|
function handleClick(e: ThreeEvent<MouseEvent>) {
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorV
|
|||||||
const current = objectTypeRef.current;
|
const current = objectTypeRef.current;
|
||||||
const serialized = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
|
const serialized = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
|
||||||
current.program = serialized;
|
current.program = serialized;
|
||||||
|
|
||||||
|
console.log(serialized);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
current.javascript = generateCode(workspace);
|
current.javascript = generateCode(workspace);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,18 @@
|
|||||||
import type { Game, RuntimeGameScene, RuntimeScene, World } from "../types";
|
import { toJS } from "mobx";
|
||||||
import type { GameEventContext, GameEventHandler, GeneratorEventHandler, InternalGameEventBus } from "../types/runtime/gameBus";
|
import type { Game, RuntimeGameObjectInstance, RuntimeGameScene, RuntimeScene, ScriptAction, ScriptCoroutineContext, World } from "../types";
|
||||||
import { clone } from "../utils";
|
import { clone } from "../utils";
|
||||||
import { populateRuntimeScene } from "../utils/runtime";
|
import { populateRuntimeScene } from "../utils/runtime";
|
||||||
import { ObjectApi } from "../utils/runtime/objectApi";
|
import { ScriptGameEventBus } from "../scripts/eventBus";
|
||||||
import type { GameScheduler, YieldCommand } from "./gameScheduler";
|
import * as ScriptActions from "../scripts/actions";
|
||||||
|
import type { ScriptCoroutines } from "../scripts/coroutines";
|
||||||
|
|
||||||
export class GameEventBus {
|
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private handlers = new Map<string, GameEventHandler[]>();
|
// type ScriptActionHelperFunction<T extends abstract new (...args: any[]) => ScriptAction> =
|
||||||
|
// (...args: ConstructorParameters<T>) => InstanceType<T>;
|
||||||
|
|
||||||
on(event: string, handler: GameEventHandler): void {
|
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
console.log('subscribing to ' + event, handler);
|
// const scriptActionHelperFunction = <T extends new (...args: any[]) => ScriptAction>(Cls: T): ScriptActionHelperFunction<T> =>
|
||||||
let set = this.handlers.get(event);
|
// (...args) => new Cls(...args) as InstanceType<T>;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GameFactory {
|
export class GameFactory {
|
||||||
|
|
||||||
@@ -66,47 +45,60 @@ export class GameFactory {
|
|||||||
public static evalGameScripts(
|
public static evalGameScripts(
|
||||||
world: World,
|
world: World,
|
||||||
scene: RuntimeGameScene,
|
scene: RuntimeGameScene,
|
||||||
eventBus: GameEventBus,
|
coroutines: ScriptCoroutines,
|
||||||
scheduler: GameScheduler,
|
): ScriptGameEventBus {
|
||||||
): void {
|
|
||||||
|
|
||||||
const handlerWrappers = new WeakMap<GeneratorEventHandler, GameEventHandler>();
|
const eventBus = new ScriptGameEventBus(coroutines);
|
||||||
|
|
||||||
const wait = (seconds: number): YieldCommand => ({ type: 'waitSeconds', seconds });
|
const scriptHelpers = {
|
||||||
const waitFrames = (frames: number): YieldCommand => ({ type: 'waitFrames', frames });
|
// convert all ScriptActions imported to a structure factory(coroutine):
|
||||||
const waitUntil = (condition: () => boolean): YieldCommand => ({ type: 'waitUntil', condition });
|
// {
|
||||||
|
// WaitSeconds: (seconds) => new WaitSecondsScriptAction(coroutine, seconds),
|
||||||
const internalBus: InternalGameEventBus = {
|
// ...
|
||||||
onGameEvent(event, genFn) {
|
// }
|
||||||
const wrapper: GameEventHandler = (ctx) => scheduler.startCoroutine(genFn(ctx) as Generator<YieldCommand, void, void>);
|
actions: (context: ScriptCoroutineContext) => Object.fromEntries(
|
||||||
handlerWrappers.set(genFn, wrapper);
|
Object.entries(ScriptActions)
|
||||||
eventBus.on(event, wrapper);
|
.map(([name, Cls]) => [
|
||||||
},
|
name.replace(/ScriptAction$/, ''),
|
||||||
offGameEvent(event, genFn) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const wrapper = handlerWrappers.get(genFn);
|
(...args: any[]) => new (Cls as any)(context, ...args),
|
||||||
if (wrapper) {
|
])
|
||||||
eventBus.off(event, wrapper);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
handlerWrappers.delete(genFn);
|
) as Record<string, (...args: any[]) => ScriptAction>,
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
|
||||||
Object.values(world.objectTypes)
|
Object.values(world.objectTypes)
|
||||||
.filter((ot) => ot.javascript)
|
.filter((ot) => ot.javascript)
|
||||||
.forEach((ot) => {
|
.forEach((ot) => {
|
||||||
const gameScript = eval(`(function gameScript({onGameEvent, offGameEvent, wait, waitFrames, waitUntil}, {api, object, objectType}) {${ot.javascript}})`);
|
try {
|
||||||
|
const javascript = `(function gameScript(eventBus, helpers, thisObject, thisObjectType) {${ot.javascript}})`;
|
||||||
|
const gameScript = eval(javascript);
|
||||||
|
|
||||||
Object.values(scene.objects)
|
Object.values(scene.objects)
|
||||||
.filter((o) => o.typeId == ot.id)
|
.filter((o) => o.typeId == ot.id)
|
||||||
|
.map((liveObj) => {
|
||||||
|
const snapshot = toJS(liveObj) as RuntimeGameObjectInstance;
|
||||||
|
snapshot.rb = () => liveObj.rb();
|
||||||
|
return snapshot;
|
||||||
|
})
|
||||||
.forEach((o) => {
|
.forEach((o) => {
|
||||||
const api = new ObjectApi(o, ot, world, scene);
|
try {
|
||||||
gameScript({ ...internalBus, wait, waitFrames, waitUntil }, { api, object: o, objectType: ot });
|
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) {
|
catch (err) {
|
||||||
console.log('Error running game script:\n' + 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 { makeAutoObservable, reaction, toJS } from "mobx";
|
||||||
import type { WorldState } from "./worldState";
|
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 type { CameraProps } from "@react-three/fiber";
|
||||||
import { GameEventBus, GameFactory } from "../model/gameFactory";
|
import type { ScriptGameEventBus } from "../scripts/eventBus";
|
||||||
import { GameScheduler } from "../model/gameScheduler";
|
import { GameFactory } from "../model/gameFactory";
|
||||||
import type { GameEventContext } from "../types/runtime/gameBus";
|
import { ScriptCoroutines } from "../scripts/coroutines";
|
||||||
|
|
||||||
export class GameState {
|
export class GameState {
|
||||||
private readonly world: WorldState;
|
private readonly world: WorldState;
|
||||||
@@ -14,8 +14,8 @@ export class GameState {
|
|||||||
public isFirstTick: boolean;
|
public isFirstTick: boolean;
|
||||||
public scene: RuntimeGameScene;
|
public scene: RuntimeGameScene;
|
||||||
|
|
||||||
private eventBus: GameEventBus;
|
private coroutines = new ScriptCoroutines();
|
||||||
private scheduler: GameScheduler;
|
private eventBus: ScriptGameEventBus;
|
||||||
|
|
||||||
private startAutoSave() {
|
private startAutoSave() {
|
||||||
return reaction(
|
return reaction(
|
||||||
@@ -45,17 +45,13 @@ export class GameState {
|
|||||||
this.isFirstTick = true;
|
this.isFirstTick = true;
|
||||||
this.scene = game.scene;
|
this.scene = game.scene;
|
||||||
|
|
||||||
const eventBus = new GameEventBus();
|
|
||||||
this.eventBus = eventBus;
|
|
||||||
this.scheduler = new GameScheduler();
|
|
||||||
|
|
||||||
this._stopAutoSave = this.startAutoSave();
|
this._stopAutoSave = this.startAutoSave();
|
||||||
makeAutoObservable(this);
|
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(
|
this.eventBus.emit(
|
||||||
event,
|
event,
|
||||||
{
|
{
|
||||||
@@ -128,7 +124,8 @@ export class GameState {
|
|||||||
if (this.isPaused)
|
if (this.isPaused)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.scheduler.tick(deltaTime);
|
this.coroutines.tick(deltaTime);
|
||||||
|
|
||||||
this.time += deltaTime;
|
this.time += deltaTime;
|
||||||
|
|
||||||
if (this.isFirstTick) {
|
if (this.isFirstTick) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './3d';
|
export * from './3d';
|
||||||
export * from './model';
|
export * from './model';
|
||||||
export * from './runtime';
|
export * from './runtime';
|
||||||
|
export * from './scripts';
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import type { V3 } from "../3d";
|
|||||||
import type { VoxelGroup } from "../runtime";
|
import type { VoxelGroup } from "../runtime";
|
||||||
import type { ObjectInstance } from "./object";
|
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 = {
|
export type ObjectInstanceRuntimeData = {
|
||||||
cache: {
|
cache: {
|
||||||
voxelGroups: VoxelGroup[];
|
voxelGroups: VoxelGroup[];
|
||||||
@@ -9,9 +16,7 @@ export type ObjectInstanceRuntimeData = {
|
|||||||
boundingBox: { min: V3; max: V3 };
|
boundingBox: { min: V3; max: V3 };
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
pendingActions: {
|
rb: () => RigidBodyHandle | null;
|
||||||
impulse?: { direction: V3, amplitude: number };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Runtime<T extends ObjectInstance> = T & ObjectInstanceRuntimeData;
|
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),
|
boundingBox: computeBoundingBox(objectType),
|
||||||
updatedAt: 0,
|
updatedAt: 0,
|
||||||
},
|
},
|
||||||
pendingActions: {},
|
rb: () => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function depopulateRuntimeObject(object: RuntimeObjectInstance, _world: World): ObjectInstance {
|
export function depopulateRuntimeObject(object: RuntimeObjectInstance, _world: World): ObjectInstance {
|
||||||
const { cache: _cache, ...result } = object;
|
const { cache: _cache, rb: _rb, ...result } = object;
|
||||||
return result;
|
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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 8888,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user