Decorators and components
This commit is contained in:
parent
6b6fd8970d
commit
ab68a5ffc6
|
|
@ -70,6 +70,10 @@ function connect() {
|
|||
connect();
|
||||
</script>`;
|
||||
|
||||
const POLYFILL = `<script>
|
||||
if (!Symbol.metadata) Symbol.metadata = Symbol.for('Symbol.metadata');
|
||||
</script>`;
|
||||
|
||||
async function buildBundle(game: string, production: boolean) {
|
||||
const assetsDir = path.resolve(import.meta.dir, 'assets');
|
||||
const srcDir = path.resolve(import.meta.dir, '..', 'src');
|
||||
|
|
@ -188,7 +192,7 @@ export async function buildHTML(game: string, {
|
|||
}
|
||||
|
||||
script = script.replaceAll('await Promise.resolve().then(() =>', '(');
|
||||
let scriptPrefix = '';
|
||||
let scriptPrefix = POLYFILL;
|
||||
if (production) {
|
||||
const minifyResult = UglifyJS.minify(script, {
|
||||
module: true,
|
||||
|
|
@ -201,7 +205,7 @@ export async function buildHTML(game: string, {
|
|||
}
|
||||
} else if (mobile) {
|
||||
const eruda = await Bun.file(path.resolve(import.meta.dir, '..', 'node_modules', 'eruda', 'eruda.js')).text();
|
||||
scriptPrefix = `<script>${eruda};\neruda.init();</script>`;
|
||||
scriptPrefix += `<script>${eruda};\neruda.init();</script>`;
|
||||
}
|
||||
if (!local) {
|
||||
scriptPrefix += SW_SCRIPT;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import type { RPGActions, RPGVariables } from "../types";
|
||||
import { ACTION_KEYS, VARIABLE_KEYS } from "../decorators";
|
||||
|
||||
export interface RPGComponent {
|
||||
getVariables: () => RPGVariables;
|
||||
getActions: () => RPGActions;
|
||||
}
|
||||
|
||||
export abstract class RPGComponentBase implements RPGComponent {
|
||||
getVariables(): RPGVariables {
|
||||
const meta = (this.constructor as Function)[Symbol.metadata];
|
||||
const keys = meta?.[VARIABLE_KEYS] as Map<string | symbol, string> | undefined;
|
||||
if (!keys) return {};
|
||||
const vars: RPGVariables = {};
|
||||
for (const [methodKey, exportName] of keys) {
|
||||
const k = String(methodKey);
|
||||
const v = (this as unknown as Record<string, RPGVariables[string]>)[k];
|
||||
if (v != null) {
|
||||
vars[exportName] = v;
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
getActions(): RPGActions {
|
||||
const meta = (this.constructor as Function)[Symbol.metadata];
|
||||
const keys = meta?.[ACTION_KEYS] as Set<string | symbol> | undefined;
|
||||
if (!keys) return {};
|
||||
const actions: RPGActions = {};
|
||||
for (const key of keys) {
|
||||
const k = String(key);
|
||||
actions[k] = (arg?: unknown) => (this as unknown as Record<string, (a?: unknown) => unknown>)[k](arg);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
export class RPGEntity implements RPGComponent {
|
||||
private components = new Map<string, RPGComponent>();
|
||||
|
||||
addComponent(id: string, component: RPGComponent): void {
|
||||
this.components.set(id, component);
|
||||
}
|
||||
|
||||
removeComponent(id: string): void {
|
||||
this.components.delete(id);
|
||||
}
|
||||
|
||||
getComponent<T extends RPGComponent>(id: string): T | undefined {
|
||||
return this.components.get(id) as T | undefined;
|
||||
}
|
||||
|
||||
getVariables(): RPGVariables {
|
||||
const variables: RPGVariables = {};
|
||||
for (const [componentKey, component] of this.components) {
|
||||
for (const [key, value] of Object.entries(component.getVariables())) {
|
||||
if (value != null) {
|
||||
variables[`${componentKey}.${key}`] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
getActions() {
|
||||
const actions: RPGActions = {};
|
||||
for (const [componentKey, component] of this.components) {
|
||||
for (const [key, action] of Object.entries(component.getActions())) {
|
||||
actions[`${componentKey}.${key}`] = action;
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,24 @@
|
|||
import type { InventoryOptions, InventorySlotInput, SlotId } from "./types";
|
||||
import type { InventoryOptions, InventorySlotInput, SlotId } from "../types";
|
||||
import { action } from "../decorators";
|
||||
import { RPGComponentBase } from "./entity";
|
||||
|
||||
interface SlotEntry {
|
||||
readonly slotId: SlotId;
|
||||
readonly limit: number | undefined;
|
||||
state: { itemId: string; amount: number } | null;
|
||||
}
|
||||
interface SlotUpdateArgs {
|
||||
itemId: string;
|
||||
amount: number;
|
||||
slotId?: SlotId;
|
||||
}
|
||||
|
||||
export class Inventory {
|
||||
export class Inventory extends RPGComponentBase {
|
||||
private readonly slots: Map<SlotId, SlotEntry>;
|
||||
private readonly maxAmountPerItem: Record<string, number>;
|
||||
|
||||
constructor(slotDefs: Array<InventorySlotInput>, options?: InventoryOptions) {
|
||||
super();
|
||||
this.slots = new Map(
|
||||
slotDefs.map(def => {
|
||||
const slotId = typeof def === 'object' ? def.slotId : def;
|
||||
|
|
@ -34,7 +42,8 @@ export class Inventory {
|
|||
return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0);
|
||||
}
|
||||
|
||||
addItem(itemId: string, amount: number, slotId?: SlotId): boolean {
|
||||
@action
|
||||
addItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
||||
if (amount < 0) return false;
|
||||
if (amount === 0) return true;
|
||||
|
||||
|
|
@ -78,7 +87,8 @@ export class Inventory {
|
|||
return remaining === 0;
|
||||
}
|
||||
|
||||
removeItem(itemId: string, amount: number, slotId?: SlotId): boolean {
|
||||
@action
|
||||
removeItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean {
|
||||
if (amount < 0) return false;
|
||||
if (amount === 0) return true;
|
||||
|
||||
|
|
@ -129,11 +139,7 @@ export class Inventory {
|
|||
return result;
|
||||
}
|
||||
|
||||
getVariables(): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const [itemId, amount] of this.getItems()) {
|
||||
result[`inventory.${itemId}`] = amount;
|
||||
}
|
||||
return result;
|
||||
override getVariables(): Record<string, number> {
|
||||
return Object.fromEntries(this.getItems());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { action, variable } from "../decorators";
|
||||
import { RPGComponentBase } from "./entity";
|
||||
|
||||
export class Stat extends RPGComponentBase {
|
||||
@variable private value: number;
|
||||
@variable private maxValue: number | undefined;
|
||||
|
||||
constructor(value: number, maxValue?: number) {
|
||||
super();
|
||||
this.value = value;
|
||||
this.maxValue = maxValue;
|
||||
}
|
||||
|
||||
@action
|
||||
update(amount: number) {
|
||||
this.value = Math.max(0, this.value + amount);
|
||||
if (this.maxValue) {
|
||||
this.value = Math.min(this.value, this.maxValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { RPGVariables } from "../types";
|
||||
import { action } from "../decorators";
|
||||
import { RPGComponentBase } from "./entity";
|
||||
|
||||
interface Var {
|
||||
key: string;
|
||||
value: RPGVariables[string];
|
||||
}
|
||||
|
||||
export class Variables extends RPGComponentBase {
|
||||
private readonly variables: RPGVariables = {};
|
||||
|
||||
override getVariables() {
|
||||
return this.variables;
|
||||
}
|
||||
|
||||
@action
|
||||
set({ key, value }: Var) {
|
||||
this.variables[key] = value;
|
||||
return this.variables;
|
||||
}
|
||||
|
||||
@action
|
||||
unset(key: string) {
|
||||
delete this.variables[key];
|
||||
return this.variables;
|
||||
}
|
||||
|
||||
@action
|
||||
increment({ key, value }: Var) {
|
||||
const currentValue = this.variables[key] ?? 0;
|
||||
if (typeof currentValue === 'number' && typeof value === 'number') {
|
||||
this.variables[key] = currentValue + value;
|
||||
}
|
||||
return this.variables;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { RPGVariables } from "./types";
|
||||
|
||||
export const ACTION_KEYS = Symbol('rpg.actions');
|
||||
export const VARIABLE_KEYS = Symbol('rpg.variables');
|
||||
|
||||
export function action<T extends (arg?: any) => unknown>(
|
||||
target: T,
|
||||
context: ClassMethodDecoratorContext<unknown, T>
|
||||
): T {
|
||||
const prev = context.metadata[ACTION_KEYS] as Set<string | symbol> | undefined;
|
||||
context.metadata[ACTION_KEYS] = new Set(prev).add(context.name);
|
||||
return target;
|
||||
}
|
||||
|
||||
type VariableContext<T extends RPGVariables[string]> =
|
||||
| ClassFieldDecoratorContext<unknown, T>
|
||||
| ClassGetterDecoratorContext<unknown, T>;
|
||||
|
||||
function registerVariable(context: VariableContext<RPGVariables[string]>, exportName: string): void {
|
||||
const prev = context.metadata[VARIABLE_KEYS] as Map<string | symbol, string> | undefined;
|
||||
context.metadata[VARIABLE_KEYS] = new Map(prev).set(context.name, exportName);
|
||||
}
|
||||
|
||||
export function variable(name: string): <T extends RPGVariables[string]>(target: undefined | (() => T), context: VariableContext<T>) => void;
|
||||
export function variable<T extends RPGVariables[string]>(target: undefined, context: ClassFieldDecoratorContext<unknown, T>): void;
|
||||
export function variable<T extends RPGVariables[string]>(target: () => T, context: ClassGetterDecoratorContext<unknown, T>): void;
|
||||
export function variable(
|
||||
nameOrTarget: string | undefined | (() => RPGVariables[string]),
|
||||
context?: VariableContext<RPGVariables[string]>
|
||||
): unknown {
|
||||
if (typeof nameOrTarget === 'string') {
|
||||
const exportName = nameOrTarget;
|
||||
return (_target: unknown, ctx: VariableContext<RPGVariables[string]>) => {
|
||||
registerVariable(ctx, exportName);
|
||||
};
|
||||
}
|
||||
registerVariable(context!, String(context!.name));
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { RPGComponentBase } from "./components/entity";
|
||||
import { evaluateConditions } from "./conditions";
|
||||
import { action, variable } from "./decorators";
|
||||
import {
|
||||
isQuest,
|
||||
type Quest,
|
||||
|
|
@ -10,8 +12,8 @@ import {
|
|||
export type QuestStatus = 'inactive' | 'active' | 'completed';
|
||||
|
||||
export interface QuestRuntimeOptions {
|
||||
variables: RPGVariables;
|
||||
actions: RPGActions;
|
||||
getVariables(): RPGVariables;
|
||||
getActions(): RPGActions;
|
||||
}
|
||||
|
||||
export namespace Quests {
|
||||
|
|
@ -37,16 +39,15 @@ export namespace Quests {
|
|||
}
|
||||
}
|
||||
|
||||
export class QuestEngine {
|
||||
private _status: QuestStatus = 'inactive';
|
||||
private _stageIndex: number = 0;
|
||||
export class QuestEngine extends RPGComponentBase {
|
||||
@variable('status') private _status: QuestStatus = 'inactive';
|
||||
@variable('stage') private _stageIndex: number = 0;
|
||||
|
||||
constructor(
|
||||
private readonly quest: Quest,
|
||||
private readonly options: QuestRuntimeOptions,
|
||||
) {
|
||||
this.quest = quest;
|
||||
this.options = options;
|
||||
super();
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
|
|
@ -54,31 +55,29 @@ export class QuestEngine {
|
|||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return evaluateConditions(this.resolveConditions(this.quest.conditions ?? []), this.options.variables);
|
||||
return evaluateConditions(this.quest.conditions ?? [], this.options.getVariables());
|
||||
}
|
||||
|
||||
@action
|
||||
start(): void {
|
||||
this._status = 'active';
|
||||
this._stageIndex = 0;
|
||||
}
|
||||
|
||||
private resolveConditions(conditions: string[]): string[] {
|
||||
const questVar = `quest.${this.quest.id}`;
|
||||
return conditions.map(c => c.replace(/^(~?)\$/, `$1${questVar}`));
|
||||
}
|
||||
|
||||
@action
|
||||
async checkAndAdvance(): Promise<void> {
|
||||
if (this._status !== 'active') return;
|
||||
|
||||
const stage = this.quest.stages[this._stageIndex];
|
||||
if (!stage) return;
|
||||
|
||||
const allDone = evaluateConditions(this.resolveConditions(stage.objectives.map(obj => obj.condition)), this.options.variables);
|
||||
const allDone = evaluateConditions(stage.objectives.map(obj => obj.condition), this.options.getVariables());
|
||||
|
||||
if (!allDone) return;
|
||||
|
||||
const actions = this.options.getActions();
|
||||
for (const action of stage.actions) {
|
||||
await this.options.actions[action.type]?.(action.arg);
|
||||
await actions[action.type]?.(action.arg);
|
||||
}
|
||||
|
||||
if (this._stageIndex + 1 < this.quest.stages.length) {
|
||||
|
|
@ -88,14 +87,6 @@ export class QuestEngine {
|
|||
}
|
||||
}
|
||||
|
||||
getVariables(): RPGVariables {
|
||||
const prefix = `quest.${this.quest.id}`;
|
||||
return {
|
||||
[prefix]: this._status,
|
||||
[`${prefix}.stage`]: this._stageIndex,
|
||||
};
|
||||
}
|
||||
|
||||
get currentStage(): QuestStage | null {
|
||||
return this.quest.stages[this._stageIndex] ?? null;
|
||||
}
|
||||
|
|
@ -105,32 +96,39 @@ export class QuestEngine {
|
|||
}
|
||||
}
|
||||
|
||||
export class QuestManager {
|
||||
export class QuestManager extends RPGComponentBase {
|
||||
private readonly engines: Map<string, QuestEngine>;
|
||||
|
||||
constructor(quests: Quest[], options: QuestRuntimeOptions) {
|
||||
super();
|
||||
this.engines = new Map(quests.map(q => [q.id, new QuestEngine(q, options)]));
|
||||
}
|
||||
|
||||
@action
|
||||
start(questId: string): void {
|
||||
this.engines.get(questId)?.start();
|
||||
}
|
||||
|
||||
@action
|
||||
async checkAndAdvance(): Promise<void> {
|
||||
for (const engine of this.engines.values()) {
|
||||
await engine.checkAndAdvance();
|
||||
}
|
||||
}
|
||||
|
||||
getVariables(): RPGVariables {
|
||||
const result: RPGVariables = {};
|
||||
for (const engine of this.engines.values()) {
|
||||
Object.assign(result, engine.getVariables());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getEngine(questId: string): QuestEngine | undefined {
|
||||
return this.engines.get(questId);
|
||||
}
|
||||
|
||||
override getVariables(): RPGVariables {
|
||||
const result: RPGVariables = {};
|
||||
for (const [key, engine] of this.engines) {
|
||||
for (const [varKey, value] of Object.entries(engine.getVariables())) {
|
||||
if (value != null) {
|
||||
result[`${key}.${varKey}`] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
export type RPGCondition = string;
|
||||
export type RPGVariables = Record<string, string | number | boolean>;
|
||||
export type RPGVariables = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
export interface RPGAction {
|
||||
type: string;
|
||||
arg?: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export type RPGActions = Record<string, (arg?: unknown) => Promise<void> | void>;
|
||||
export type RPGActions = Record<string, (arg?: any) => unknown>;
|
||||
|
||||
export interface DialogChoice {
|
||||
text: string;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,41 @@
|
|||
import { DialogEngine, Dialogs } from "@common/rpg/dialog";
|
||||
import dialogYml from './dialog.yml';
|
||||
import { isDialog } from "@common/rpg/types";
|
||||
import { RPGEntity } from "@common/rpg/components/entity";
|
||||
import { Inventory } from "@common/rpg/components/inventory";
|
||||
import { Stat } from "@common/rpg/components/stat";
|
||||
import { Variables } from "@common/rpg/components/variables";
|
||||
import { QuestManager } from "@common/rpg/quest";
|
||||
|
||||
|
||||
export default async function main() {
|
||||
// console.log(dialogYml);
|
||||
if (isDialog(dialogYml)) {
|
||||
console.log(await Dialogs.coverageTest(dialogYml));
|
||||
}
|
||||
const game = new RPGEntity();
|
||||
const player = new RPGEntity();
|
||||
const inventory = new Inventory(['head', 'legs']);
|
||||
const quests = new QuestManager([{
|
||||
id: 'test',
|
||||
description: 'Test quest',
|
||||
title: 'Test',
|
||||
stages: [],
|
||||
}], game);
|
||||
const vars = new Variables();
|
||||
|
||||
game.addComponent('variables', vars);
|
||||
game.addComponent('player', player);
|
||||
game.addComponent('quests', quests);
|
||||
player.addComponent('inventory', inventory);
|
||||
player.addComponent('health', new Stat(100));
|
||||
console.log(game.getActions());
|
||||
|
||||
inventory.addItem({
|
||||
itemId: 'helmet',
|
||||
amount: 1,
|
||||
slotId: 'head',
|
||||
});
|
||||
inventory.addItem({
|
||||
itemId: 'boots',
|
||||
amount: 2,
|
||||
});
|
||||
inventory.addItem({
|
||||
itemId: 'belt',
|
||||
amount: 1,
|
||||
});
|
||||
console.log(game.getVariables());
|
||||
}
|
||||
|
|
@ -551,7 +551,7 @@ export default class TileMap extends Entity {
|
|||
}
|
||||
}
|
||||
|
||||
public update(dt: number) {
|
||||
public override update(dt: number) {
|
||||
this.players.forEach((player) => player.update(dt));
|
||||
this.enemies.forEach((tile) => tile.enemy!.update(dt));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
|
|
|
|||
Loading…
Reference in New Issue