diff --git a/.gitignore b/.gitignore index 0a1f1f0..8960f47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules -dist +*node_modules +*dist *.tsbuildinfo .DS_Store diff --git a/engine/package-lock.json b/engine/package-lock.json new file mode 100644 index 0000000..3878b0f --- /dev/null +++ b/engine/package-lock.json @@ -0,0 +1,39 @@ +{ + "name": "@incgame/engine", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@incgame/engine", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "break_eternity.js": "^2.1.3" + }, + "devDependencies": { + "typescript": "^5.6.3" + } + }, + "node_modules/break_eternity.js": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/break_eternity.js/-/break_eternity.js-2.1.3.tgz", + "integrity": "sha512-4tg4j0wc0lhaYAnOHubN5mAyHbhMfUI7adQLO8l/loKqtylZ/kHWp8WYqG2EC0TinSesKvpCi3XeVFcKRUBJsQ==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/engine/package.json b/engine/package.json new file mode 100644 index 0000000..8ba037a --- /dev/null +++ b/engine/package.json @@ -0,0 +1,20 @@ +{ + "name": "@incgame/engine", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "break_eternity.js": "^2.1.3" + }, + "devDependencies": { + "typescript": "^5.6.3" + } +} diff --git a/engine/src/behaviours/base.ts b/engine/src/behaviours/base.ts new file mode 100644 index 0000000..ed19a93 --- /dev/null +++ b/engine/src/behaviours/base.ts @@ -0,0 +1,61 @@ +import Decimal, { DecimalSource } from "break_eternity.js"; +import { BehaviourDisplayInfo, BehaviourLink, BehaviourSocket, Resource, Resources, SerializedBehaviour } from "../types"; + +export abstract class Behaviour { + public readonly id: string; + public readonly kind: string; + public readonly inputLinks: BehaviourLink[]; + + constructor(source: SerializedBehaviour) { + this.id = source.id; + this.kind = source.kind; + this.inputLinks = source.inputLinks.pins; + } + + public getDisplayInfo(): BehaviourDisplayInfo { + return { + storages: {}, + sockets: [ + ...this.inputs.map((input, index) => ({ + type: 'input', + index, + resource: input, + } satisfies BehaviourSocket)), + ...this.outputs.map((output, index) => ({ + type: 'output', + index, + resource: output, + } satisfies BehaviourSocket)), + ], + }; + } + + public abstract get inputs(): Resource[]; + public abstract get outputs(): Resource[]; + + /** Withdraws up to `amount` of `resource` from this behaviour's storage, returning how much was actually taken. */ + public abstract take(resource: Resource, amount: DecimalSource): Decimal; + + protected abstract give(resource: Resource, amount: DecimalSource): void; + + /** Snapshot of everything currently held, keyed by resource. */ + public abstract getStorage(): Partial; + + abstract reset(): void; + + /** + * Advances this behaviour by `deltaTime`. Must pull whatever inputs it needs (via `resolve`) + * before advancing its own cycle, so that a cycle only completes once enough input was actually + * available — otherwise it stalls and tries again on a later tick. + * + * `fairShare(sourceId, resource)` caps how much of `resource` may be pulled from `sourceId` this + * tick: when several behaviours are linked to the same source for the same resource, each is + * capped to an equal share of what that source held right after its own tick, regardless of + * pull order, instead of whoever asks first draining it. + */ + abstract tick( + deltaTime: number, + resolve: (id: string) => Behaviour | undefined, + fairShare: (sourceId: string, resource: Resource) => Decimal, + ): void; +} diff --git a/engine/src/behaviours/converter.ts b/engine/src/behaviours/converter.ts new file mode 100644 index 0000000..d1eca19 --- /dev/null +++ b/engine/src/behaviours/converter.ts @@ -0,0 +1,125 @@ +import Decimal, { DecimalSource } from "break_eternity.js"; +import { BehaviourDisplayInfo, Resource, Resources, SerializedBehaviour, SerializedConverter } from "../types"; +import { Behaviour } from "./base"; + +export class Converter extends Behaviour { + protected readonly storage: Partial; + public cycleTime: Decimal; + public readonly cycleLength: Decimal; + public readonly inputRates: Partial; + public readonly outputRates: Partial; + + constructor(source: SerializedBehaviour & SerializedConverter) { + if (source.kind !== 'converter') + throw new Error('Invalid source'); + super(source); + + this.cycleTime = new Decimal(source.cycleTime); + this.cycleLength = new Decimal(source.rules.cycleLength); + + this.storage = {}; + for (const [res, amount] of Object.entries(source.storage ?? {})) + this.storage[res as Resource] = new Decimal(amount); + + this.inputRates = {}; + for (const [res, rate] of Object.entries(source.rules.inputRates ?? {})) + this.inputRates[res as Resource] = new Decimal(rate); + + this.outputRates = {}; + for (const [res, rate] of Object.entries(source.rules.outputRates ?? {})) + this.outputRates[res as Resource] = new Decimal(rate); + } + + public get inputs(): Resource[] { + return Object.keys(this.inputRates) as Resource[]; + } + + public get outputs(): Resource[] { + return Object.keys(this.outputRates) as Resource[]; + } + + /** + * A converter grabs a full batch of each input the moment enough is available — reserving + * ahead for however many cycles (including the one currently underway) the elapsed time could + * reach, rather than trickling resources in proportionally as time passes. If the source can't + * supply the full batch, `cycleTime` only advances as far as what was actually secured + * justifies: a converter can't be "cooking" a batch it doesn't have the ingredients for, so it + * stalls proportionally to whatever is actually missing rather than free-running on elapsed + * time alone. + */ + public tick( + deltaTime: number, + resolve: (id: string) => Behaviour | undefined, + fairShare: (sourceId: string, resource: Resource) => Decimal, + ): void { + const idealCycleTime = this.cycleTime.add(deltaTime); + const reserveCycles = idealCycleTime.div(this.cycleLength).ceil(); + let newCycleTime = idealCycleTime; + + for (const [res, rate] of Object.entries(this.inputRates) as [Resource, Decimal][]) { + if (rate.eq(0)) + continue; + + const target = rate.mul(reserveCycles); + const have = this.storage[res] ?? Decimal.dZero; + const shortfall = target.sub(have).max(0); + if (shortfall.gt(0)) { + const link = this.inputLinks.find((l) => l.resource === res); + if (link) { + const source = resolve(link.target); + if (source) { + const request = shortfall.min(fairShare(link.target, link.resource)); + if (request.gt(0)) + this.give(res, source.take(link.resource, request)); + } + } + } + + const haveNow = this.storage[res] ?? Decimal.dZero; + newCycleTime = newCycleTime.min(haveNow.mul(this.cycleLength).div(rate)); + } + + this.cycleTime = newCycleTime; + const affordableCycles = this.cycleTime.div(this.cycleLength).floor(); + + if (affordableCycles.gt(0)) { + for (const [res, rate] of Object.entries(this.inputRates) as [Resource, Decimal][]) + this.take(res, rate.mul(affordableCycles)); + for (const [res, rate] of Object.entries(this.outputRates) as [Resource, Decimal][]) + this.give(res, rate.mul(affordableCycles)); + this.cycleTime = this.cycleTime.sub(affordableCycles.mul(this.cycleLength)); + } + } + + public take(resource: Resource, amount: DecimalSource): Decimal { + const available = this.storage[resource] ?? Decimal.dZero; + const taken = available.min(amount); + this.storage[resource] = available.sub(taken); + return taken; + } + + protected give(resource: Resource, amount: DecimalSource): void { + this.storage[resource] = (this.storage[resource] ?? Decimal.dZero).add(amount); + } + + public getStorage(): Partial { + return { ...this.storage }; + } + + public getDisplayInfo(): BehaviourDisplayInfo { + const info = super.getDisplayInfo(); + info.storages['Буфер'] = { ...this.storage } as Resources; + return { + ...info, + raw: { + inputRates: this.inputRates, + outputRates: this.outputRates, + }, + } as any; + } + + public reset() { + for (const res of Object.keys(this.storage)) + delete (this.storage[res as Resource]); + } +} diff --git a/engine/src/behaviours/index.ts b/engine/src/behaviours/index.ts new file mode 100644 index 0000000..d21aae1 --- /dev/null +++ b/engine/src/behaviours/index.ts @@ -0,0 +1,3 @@ +export * from './base'; +export * from './converter'; +export * from './storage'; diff --git a/engine/src/behaviours/storage.ts b/engine/src/behaviours/storage.ts new file mode 100644 index 0000000..83d7e16 --- /dev/null +++ b/engine/src/behaviours/storage.ts @@ -0,0 +1,83 @@ +import Decimal, { DecimalSource } from "break_eternity.js"; +import { BehaviourDisplayInfo, Resource, Resources, SerializedBehaviour, SerializedStorage } from "../types"; +import { Behaviour } from "./base"; + +export class Storage extends Behaviour { + public readonly capacity: Decimal | undefined; + public readonly acceptsResources: Resource[]; + public readonly items: Partial; + + constructor(source: SerializedBehaviour & SerializedStorage) { + if (source.kind !== 'storage') + throw new Error('Invalid source'); + super(source); + + this.capacity = source.capacity ? new Decimal(source.capacity) : undefined; + this.acceptsResources = source.acceptsResources; + this.items = {}; + for (const [res, amount] of Object.entries(source.items ?? {})) + this.items[res as Resource] = new Decimal(amount); + } + + public get inputs(): Resource[] { + return this.acceptsResources; + } + + public get outputs(): Resource[] { + return this.acceptsResources; + } + + /** + * A storage has no rate of its own — it pulls as much as its linked source is willing to give + * (up to whatever room its own capacity leaves), and later hands out as much as whoever calls + * `take` asks for (up to whatever it actually has). The speed in either direction is entirely + * up to the counterpart, not this behaviour. + */ + public tick( + _deltaTime: number, + resolve: (id: string) => Behaviour | undefined, + fairShare: (sourceId: string, resource: Resource) => Decimal, + ): void { + for (const resource of this.acceptsResources) { + const link = this.inputLinks.find((l) => l.resource === resource); + if (!link) + continue; + + const source = resolve(link.target); + if (!source) + continue; + + const have = this.items[resource] ?? Decimal.dZero; + const room = this.capacity ? this.capacity.sub(have).max(0) : Decimal.dInf; + const request = room.min(fairShare(link.target, link.resource)); + if (request.gt(0)) + this.give(resource, source.take(link.resource, request)); + } + } + + public take(resource: Resource, amount: DecimalSource): Decimal { + const available = this.items[resource] ?? Decimal.dZero; + const taken = available.min(amount); + this.items[resource] = available.sub(taken); + return taken; + } + + protected give(resource: Resource, amount: DecimalSource): void { + this.items[resource] = (this.items[resource] ?? Decimal.dZero).add(amount); + } + + public getStorage(): Partial { + return { ...this.items }; + } + + public getDisplayInfo(): BehaviourDisplayInfo { + const info = super.getDisplayInfo(); + info.storages['Хранилище'] = { ...this.items } as Resources; + return info; + } + + public reset() { + for (const res of Object.keys(this.items)) + delete (this.items[res as Resource]); + } +} diff --git a/engine/src/game.ts b/engine/src/game.ts new file mode 100644 index 0000000..2ffcf5d Binary files /dev/null and b/engine/src/game.ts differ diff --git a/engine/src/index.ts b/engine/src/index.ts new file mode 100644 index 0000000..4d3490c --- /dev/null +++ b/engine/src/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './behaviours'; +export * from './game'; +export * from './serialization'; +export * from './utils'; diff --git a/engine/src/serialization/behaviour.ts b/engine/src/serialization/behaviour.ts new file mode 100644 index 0000000..eb24388 --- /dev/null +++ b/engine/src/serialization/behaviour.ts @@ -0,0 +1,47 @@ +import { Behaviour, Converter, Storage } from "../behaviours"; +import { SerializedBehaviour, SerializedConverter, SerializedStorage } from "../types"; + +export function deserializeBehaviour(value: SerializedBehaviour): Behaviour { + switch (value.kind) { + case 'converter': + return new Converter(value); + case 'storage': + return new Storage(value); + default: + throw new Error('Unknown behaviour'); + } +} + +export function serializeBehaviour(behaviour: Behaviour): SerializedBehaviour { + const base = { + id: behaviour.id, + kind: behaviour.kind, + inputLinks: { pins: behaviour.inputLinks }, + }; + + switch (behaviour.kind) { + case 'converter': { + const converter = behaviour as Converter; + return { + ...base, + cycleTime: converter.cycleTime, + storage: converter.getStorage(), + rules: { + cycleLength: converter.cycleLength, + inputRates: converter.inputRates, + outputRates: converter.outputRates, + } + } as (SerializedBehaviour & SerializedConverter);; + } + case 'storage': + const storage = behaviour as Storage; + return { + ...base, + capacity: storage.capacity, + acceptsResources: storage.acceptsResources, + items: storage.getStorage(), + } as (SerializedBehaviour & SerializedStorage); + default: + throw new Error('Unknown behaviour'); + } +} diff --git a/engine/src/serialization/game.ts b/engine/src/serialization/game.ts new file mode 100644 index 0000000..dec3d7c --- /dev/null +++ b/engine/src/serialization/game.ts @@ -0,0 +1,26 @@ +import { Game } from "../game"; +import { SerializedGame } from "../types"; +import { serializeBehaviour } from "./behaviour"; + +export function serializeGame(game: Game): SerializedGame { + return { + time: game.time, + nodes: game.nodes.map((node) => ({ + id: node.id, + name: node.name, + behaviour: serializeBehaviour(node.behaviour), + })), + }; +} + +export function deserializeGame(value: SerializedGame): Game { + return new Game(value); +} + +export function serializeGameToString(game: Game): string { + return JSON.stringify(serializeGame(game)); +} + +export function deserializeGameFromString(value: string): Game { + return deserializeGame(JSON.parse(value)); +} diff --git a/engine/src/serialization/index.ts b/engine/src/serialization/index.ts new file mode 100644 index 0000000..c3859ce --- /dev/null +++ b/engine/src/serialization/index.ts @@ -0,0 +1,2 @@ +export * from './behaviour'; +export * from './game'; diff --git a/engine/src/serialization/unit.ts b/engine/src/serialization/unit.ts new file mode 100644 index 0000000..e69de29 diff --git a/engine/src/types/behaviour.ts b/engine/src/types/behaviour.ts new file mode 100644 index 0000000..67e881b --- /dev/null +++ b/engine/src/types/behaviour.ts @@ -0,0 +1,23 @@ +import { Resource, Resources } from "./resource"; + +export type BehaviourResourcePin = { + resource: Resource; +} + +export type BehaviourSocket = { + type: 'input' | 'output'; + index: number; // within its type + resource: Resource; +} + +export type BehaviourLink = { + target: string, + resource: Resource; +} + +export type BehaviourDisplayInfo = { + storages: Record; + sockets: BehaviourSocket[]; + + [x: string]: unknown; +} diff --git a/engine/src/types/index.ts b/engine/src/types/index.ts new file mode 100644 index 0000000..06703a4 --- /dev/null +++ b/engine/src/types/index.ts @@ -0,0 +1,4 @@ +export * from './behaviour'; +export * from './unit'; +export * from './resource'; +export * from './serialization'; diff --git a/engine/src/types/resource.ts b/engine/src/types/resource.ts new file mode 100644 index 0000000..aaf57d9 --- /dev/null +++ b/engine/src/types/resource.ts @@ -0,0 +1,5 @@ +import Decimal from "break_eternity.js"; + +export type Resource = 'tree' | 'wood' | 'stone'; + +export type Resources = Record; diff --git a/engine/src/types/serialization.ts b/engine/src/types/serialization.ts new file mode 100644 index 0000000..59983da --- /dev/null +++ b/engine/src/types/serialization.ts @@ -0,0 +1,46 @@ +import { Unit } from './unit'; +import { DecimalSource } from "break_eternity.js"; +import { Resource } from "./resource"; + +export type SerializedInputLink = { + target: SerializedBehaviour['id'], + resource: Resource, +} + +export type SerializedInputLinks = { + pins: SerializedInputLink[]; +} + +export type SerializedConverter = { + kind: 'converter'; + cycleTime?: DecimalSource; + storage?: Partial>; + rules: { + cycleLength: DecimalSource; + inputRates?: Partial>; + outputRates?: Partial>; + } +} + +export type SerializedStorage = { + kind: 'storage', + capacity?: DecimalSource; + acceptsResources: Resource[]; + items: Partial>; +} + +export type SerializedBehaviour = { + id: string; + inputLinks: SerializedInputLinks; +} & (SerializedConverter | SerializedStorage); + +export type SerializedNode = { + id: Unit['id'], + name: Unit['name'], + behaviour: SerializedBehaviour, +} + +export type SerializedGame = { + time: DecimalSource; + nodes: SerializedNode[]; +} diff --git a/engine/src/types/unit.ts b/engine/src/types/unit.ts new file mode 100644 index 0000000..9bda8b8 --- /dev/null +++ b/engine/src/types/unit.ts @@ -0,0 +1,7 @@ +import { Behaviour } from "../behaviours"; + +export type Unit = { + id: string; + name: string; + behaviour: Behaviour; +} diff --git a/engine/src/utils/index.ts b/engine/src/utils/index.ts new file mode 100644 index 0000000..48ff941 --- /dev/null +++ b/engine/src/utils/index.ts @@ -0,0 +1,25 @@ +import { Behaviour } from "../behaviours"; + +/** Orders behaviours so that whatever a behaviour's inputs link to comes before it (a dependency cycle just falls back to encounter order). */ +export function orderByDependency(behaviours: Behaviour[]): Behaviour[] { + const byId = new Map(behaviours.map((behaviour) => [behaviour.id, behaviour])); + const visited = new Set(); + const order: Behaviour[] = []; + + function visit(behaviour: Behaviour) { + if (visited.has(behaviour.id)) + return; + visited.add(behaviour.id); + for (const inputLink of behaviour.inputLinks) { + const dependency = byId.get(inputLink.target); + if (dependency) + visit(dependency); + } + order.push(behaviour); + } + + for (const behaviour of behaviours) + visit(behaviour); + + return order; +} diff --git a/engine/tsconfig.json b/engine/tsconfig.json new file mode 100644 index 0000000..483f51e --- /dev/null +++ b/engine/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ESNext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/resource-graph-vite/.gitignore b/resource-graph-vite/.gitignore new file mode 100644 index 0000000..0a1f1f0 --- /dev/null +++ b/resource-graph-vite/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.tsbuildinfo +.DS_Store diff --git a/README.md b/resource-graph-vite/README.md similarity index 100% rename from README.md rename to resource-graph-vite/README.md diff --git a/index.html b/resource-graph-vite/index.html similarity index 100% rename from index.html rename to resource-graph-vite/index.html diff --git a/package-lock.json b/resource-graph-vite/package-lock.json similarity index 100% rename from package-lock.json rename to resource-graph-vite/package-lock.json diff --git a/package.json b/resource-graph-vite/package.json similarity index 100% rename from package.json rename to resource-graph-vite/package.json diff --git a/src/App.tsx b/resource-graph-vite/src/App.tsx similarity index 100% rename from src/App.tsx rename to resource-graph-vite/src/App.tsx diff --git a/src/components/BehaviourCard.tsx b/resource-graph-vite/src/components/BehaviourCard.tsx similarity index 100% rename from src/components/BehaviourCard.tsx rename to resource-graph-vite/src/components/BehaviourCard.tsx diff --git a/src/components/ConverterCardInfo.tsx b/resource-graph-vite/src/components/ConverterCardInfo.tsx similarity index 100% rename from src/components/ConverterCardInfo.tsx rename to resource-graph-vite/src/components/ConverterCardInfo.tsx diff --git a/src/components/GameCanvas.tsx b/resource-graph-vite/src/components/GameCanvas.tsx similarity index 100% rename from src/components/GameCanvas.tsx rename to resource-graph-vite/src/components/GameCanvas.tsx diff --git a/src/components/LinkLine.tsx b/resource-graph-vite/src/components/LinkLine.tsx similarity index 100% rename from src/components/LinkLine.tsx rename to resource-graph-vite/src/components/LinkLine.tsx diff --git a/src/components/Topbar.tsx b/resource-graph-vite/src/components/Topbar.tsx similarity index 100% rename from src/components/Topbar.tsx rename to resource-graph-vite/src/components/Topbar.tsx diff --git a/src/index.css b/resource-graph-vite/src/index.css similarity index 100% rename from src/index.css rename to resource-graph-vite/src/index.css diff --git a/src/main.tsx b/resource-graph-vite/src/main.tsx similarity index 100% rename from src/main.tsx rename to resource-graph-vite/src/main.tsx diff --git a/src/prefabs/game.ts b/resource-graph-vite/src/prefabs/game.ts similarity index 100% rename from src/prefabs/game.ts rename to resource-graph-vite/src/prefabs/game.ts diff --git a/src/saveLoad.ts b/resource-graph-vite/src/saveLoad.ts similarity index 100% rename from src/saveLoad.ts rename to resource-graph-vite/src/saveLoad.ts diff --git a/src/ui/graph.ts b/resource-graph-vite/src/ui/graph.ts similarity index 100% rename from src/ui/graph.ts rename to resource-graph-vite/src/ui/graph.ts diff --git a/src/ui/resource.ts b/resource-graph-vite/src/ui/resource.ts similarity index 100% rename from src/ui/resource.ts rename to resource-graph-vite/src/ui/resource.ts diff --git a/tsconfig.app.json b/resource-graph-vite/tsconfig.app.json similarity index 100% rename from tsconfig.app.json rename to resource-graph-vite/tsconfig.app.json diff --git a/tsconfig.json b/resource-graph-vite/tsconfig.json similarity index 100% rename from tsconfig.json rename to resource-graph-vite/tsconfig.json diff --git a/tsconfig.node.json b/resource-graph-vite/tsconfig.node.json similarity index 100% rename from tsconfig.node.json rename to resource-graph-vite/tsconfig.node.json diff --git a/vite.config.ts b/resource-graph-vite/vite.config.ts similarity index 100% rename from vite.config.ts rename to resource-graph-vite/vite.config.ts