Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
325aa8915d | ||
|
|
d154880c4c | ||
|
|
75050aaaa3 | ||
|
|
c4f0fe2a60 | ||
|
|
a1c05799dd | ||
|
|
5b2452aa50 |
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
node_modules
|
*node_modules
|
||||||
dist
|
*dist
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
Generated
+39
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Decimal, { type DecimalSource } from "break_eternity.js";
|
||||||
|
import type { 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<Resources>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Decimal, { type DecimalSource } from "break_eternity.js";
|
||||||
|
import type { BehaviourDisplayInfo, Resource, Resources, SerializedBehaviour, SerializedConverter } from "../types";
|
||||||
|
import { Behaviour } from "./base";
|
||||||
|
|
||||||
|
export class Converter extends Behaviour {
|
||||||
|
protected readonly storage: Partial<Resources>;
|
||||||
|
public cycleTime: Decimal;
|
||||||
|
public readonly cycleLength: Decimal;
|
||||||
|
public readonly inputRates: Partial<Resources>;
|
||||||
|
public readonly outputRates: Partial<Resources>;
|
||||||
|
|
||||||
|
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<Resources> {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './base';
|
||||||
|
export * from './converter';
|
||||||
|
export * from './storage';
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Decimal, { type DecimalSource } from "break_eternity.js";
|
||||||
|
import type { 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<Resources>;
|
||||||
|
|
||||||
|
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<Resources> {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './behaviours';
|
||||||
|
export * from './game';
|
||||||
|
export * from './serialization';
|
||||||
|
export * from './utils';
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { type Behaviour, Converter, Storage } from "../behaviours";
|
||||||
|
import type { 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Game } from "../game";
|
||||||
|
import type { 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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './behaviour';
|
||||||
|
export * from './game';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { 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<string, Resources>;
|
||||||
|
sockets: BehaviourSocket[];
|
||||||
|
|
||||||
|
[x: string]: unknown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './behaviour';
|
||||||
|
export * from './unit';
|
||||||
|
export * from './resource';
|
||||||
|
export * from './serialization';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type Decimal from "break_eternity.js";
|
||||||
|
|
||||||
|
export type Resource = 'tree' | 'wood' | 'stone';
|
||||||
|
|
||||||
|
export type Resources = Record<Resource, Decimal>;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Unit } from './unit';
|
||||||
|
import type { DecimalSource } from "break_eternity.js";
|
||||||
|
import type { 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<Record<Resource, DecimalSource>>;
|
||||||
|
rules: {
|
||||||
|
cycleLength: DecimalSource;
|
||||||
|
inputRates?: Partial<Record<Resource, DecimalSource>>;
|
||||||
|
outputRates?: Partial<Record<Resource, DecimalSource>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SerializedStorage = {
|
||||||
|
kind: 'storage',
|
||||||
|
capacity?: DecimalSource;
|
||||||
|
acceptsResources: Resource[];
|
||||||
|
items: Partial<Record<Resource, DecimalSource>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Behaviour } from "../behaviours";
|
||||||
|
|
||||||
|
export type Unit = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
behaviour: Behaviour;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { 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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>forest</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1344
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "forest",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@incgame/engine": "file:../engine",
|
||||||
|
"break_eternity.js": "^2.1.3",
|
||||||
|
"mobx": "^7.0.3",
|
||||||
|
"mobx-react-lite": "^5.0.3",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
"oxlint": "^1.79.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,130 @@
|
|||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border-bottom: 4px solid var(--panel-border);
|
||||||
|
box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__title {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
text-shadow: 2px 2px 0 var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-slot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 3px solid var(--panel-border);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-map {
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #4caf50;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #46a047 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #46a047 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #46a047 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #46a047 75%);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
background-position: 0 0, 0 16px, 16px -16px, -16px 0px;
|
||||||
|
cursor: default;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity.log-shadow {
|
||||||
|
width: 20px;
|
||||||
|
height: 10px;
|
||||||
|
margin: 8px 0px 0px 0px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity.tree-shadow {
|
||||||
|
width: 64px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 26px 0px 0px -11px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity.character-shadow {
|
||||||
|
width: 48px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 32px 0px 0px 0px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity.tree {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px;
|
||||||
|
margin: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity.tree.tree--chopping svg {
|
||||||
|
animation: chop-shake 0.12s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity.character {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity.character.character--walking svg {
|
||||||
|
animation: walk-bob 0.3s infinite steps(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chop-shake {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate(0, 0) rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translate(-2px, 1px) rotate(-6deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(1px, -2px) rotate(5deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translate(-1px, 1px) rotate(-4deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes walk-bob {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import GameMap from './components/GameMap';
|
||||||
|
import Topbar from './components/Topbar';
|
||||||
|
import { GameStore } from './game/GameStore';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [store] = useState(() => new GameStore());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Topbar store={store} />
|
||||||
|
<GameMap store={store} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,155 @@
|
|||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { PointerEvent } from 'react';
|
||||||
|
import type { GameStore } from '../game/GameStore';
|
||||||
|
import type { LogDrop, TreeState } from '../game/types';
|
||||||
|
import { CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE } from '../game/sprites';
|
||||||
|
import PixelSprite from './PixelSprite';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each entity below is its own observer so mobx can subscribe it only to the fields it actually
|
||||||
|
// reads. Otherwise, with everything read inside GameMap's single observer, the character moving
|
||||||
|
// every animation frame would re-render the whole tree/log list too, not just itself.
|
||||||
|
|
||||||
|
const LogEntity = observer(function LogEntity({ log }: { log: LogDrop }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="entity log-shadow" style={{ left: log.x, top: log.y }} />
|
||||||
|
<div className="entity log" style={{ left: log.x, top: log.y - log.z, zIndex: Math.floor(log.y - log.z) }}>
|
||||||
|
<PixelSprite pixels={LOG_SPRITE} size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const TreeEntity = observer(function TreeEntity({
|
||||||
|
tree,
|
||||||
|
store,
|
||||||
|
onPointerDown,
|
||||||
|
onPointerUp,
|
||||||
|
}: {
|
||||||
|
tree: TreeState;
|
||||||
|
store: GameStore;
|
||||||
|
onPointerDown: (e: PointerEvent<HTMLDivElement>, id: string) => void;
|
||||||
|
onPointerUp: (e: PointerEvent<HTMLDivElement>) => void;
|
||||||
|
}) {
|
||||||
|
const isChopping = tree.id === store.heldTreeId && store.isChopping;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="entity tree-shadow" style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) - 1 }} />
|
||||||
|
<div
|
||||||
|
className={`entity tree ${isChopping ? 'tree--chopping' : ''}`}
|
||||||
|
style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) }}
|
||||||
|
onPointerDown={(e) => onPointerDown(e, tree.id)}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
>
|
||||||
|
<PixelSprite pixels={TREE_SPRITE} size={64} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="entity character-shadow"
|
||||||
|
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`entity character ${store.isMoving ? 'character--walking' : ''}`}
|
||||||
|
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
|
||||||
|
>
|
||||||
|
<PixelSprite pixels={CHARACTER_SPRITE} size={48} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function GameMap({ store }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const { width, height } = container.getBoundingClientRect();
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
store.initialize(width, height);
|
||||||
|
store.start();
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
store.stop();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('blur', store.release);
|
||||||
|
return () => window.removeEventListener('blur', store.release);
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
function handleTreePointerDown(e: PointerEvent<HTMLDivElement>, id: string) {
|
||||||
|
if (e.button !== 0)
|
||||||
|
return;
|
||||||
|
// Stop this from also bubbling to the map's own pointer handlers below (which would
|
||||||
|
// otherwise interpret the same press as "walk to this empty ground point").
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
store.holdTree(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTreePointerUp(e: PointerEvent<HTMLDivElement>) {
|
||||||
|
e.stopPropagation();
|
||||||
|
store.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapPointerDown(e: PointerEvent<HTMLDivElement>) {
|
||||||
|
if (e.button !== 0)
|
||||||
|
return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
store.moveTo(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="game-map"
|
||||||
|
ref={containerRef}
|
||||||
|
onPointerDown={handleMapPointerDown}
|
||||||
|
onPointerUp={store.release}
|
||||||
|
onPointerCancel={store.release}
|
||||||
|
>
|
||||||
|
{ready && (
|
||||||
|
<>
|
||||||
|
{store.logDrops.map((log) => (
|
||||||
|
<LogEntity key={log.id} log={log} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{store.trees.map((tree) => (
|
||||||
|
<TreeEntity
|
||||||
|
key={tree.id}
|
||||||
|
tree={tree}
|
||||||
|
store={store}
|
||||||
|
onPointerDown={handleTreePointerDown}
|
||||||
|
onPointerUp={handleTreePointerUp}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<CharacterEntity store={store} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(GameMap);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import type { PixelGrid } from '../game/sprites';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pixels: PixelGrid;
|
||||||
|
size: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PixelSprite({ pixels, size, className }: Props) {
|
||||||
|
const rows = pixels.length;
|
||||||
|
const cols = pixels[0]?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width={size}
|
||||||
|
height={cols > 0 ? size * (rows / cols) : size}
|
||||||
|
viewBox={`0 0 ${cols} ${rows}`}
|
||||||
|
shapeRendering="crispEdges"
|
||||||
|
>
|
||||||
|
{pixels.flatMap((row, y) =>
|
||||||
|
row.map((color, x) =>
|
||||||
|
color ? <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={color} /> : null,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `pixels` is always one of the module-level sprite constants and `size` is a literal passed by
|
||||||
|
// the caller, so both are reference/value-stable across re-renders — memoizing means an entity
|
||||||
|
// re-rendering every frame for its position (left/top) doesn't also regenerate this sprite's
|
||||||
|
// (potentially hundreds of) <rect> elements each time.
|
||||||
|
export default memo(PixelSprite);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import type { GameStore } from '../game/GameStore';
|
||||||
|
import { COIN_SPRITE, LOG_SPRITE } from '../game/sprites';
|
||||||
|
import PixelSprite from './PixelSprite';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
store: GameStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Topbar({ store }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="topbar">
|
||||||
|
<h1 className="topbar__title">Forest</h1>
|
||||||
|
<div className="spacer" />
|
||||||
|
<div className="inventory-slot">
|
||||||
|
<PixelSprite pixels={LOG_SPRITE} size={28} />
|
||||||
|
<span>{store.inventory.logs}</span>
|
||||||
|
</div>
|
||||||
|
<div className="inventory-slot">
|
||||||
|
<PixelSprite pixels={COIN_SPRITE} size={24} />
|
||||||
|
<span>{store.inventory.coins}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(Topbar);
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { makeAutoObservable } from 'mobx';
|
||||||
|
import type { Inventory, LogDrop, TreeState, Vec2 } from './types';
|
||||||
|
import {
|
||||||
|
CHARACTER_SPEED,
|
||||||
|
CHOP_INTERVAL,
|
||||||
|
COIN_INTERVAL,
|
||||||
|
INTERACTION_RADIUS,
|
||||||
|
LOG_COLLECT_ARC_HEIGHT,
|
||||||
|
LOG_COLLECT_DURATION,
|
||||||
|
LOG_EJECT_RADIUS,
|
||||||
|
LOG_FALL_ARC_HEIGHT,
|
||||||
|
LOG_FALL_DURATION,
|
||||||
|
LOGS_PER_TREE,
|
||||||
|
PICKUP_RADIUS,
|
||||||
|
TREE_COUNT,
|
||||||
|
TREE_EDGE_MARGIN,
|
||||||
|
TREE_MIN_DISTANCE_BETWEEN,
|
||||||
|
TREE_MIN_DISTANCE_FROM_CENTER,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
function randomId(prefix: string): string {
|
||||||
|
return `${prefix}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameStore {
|
||||||
|
character: Vec2 = { x: 0, y: 0 };
|
||||||
|
trees: TreeState[] = [];
|
||||||
|
logDrops: LogDrop[] = [];
|
||||||
|
inventory: Inventory = { logs: 0, coins: 0 };
|
||||||
|
heldTreeId: string | null = null;
|
||||||
|
/** Free-form walk destination set by pressing on empty map, mutually exclusive with heldTreeId. */
|
||||||
|
moveTarget: Vec2 | null = null;
|
||||||
|
|
||||||
|
private chopProgress = 0;
|
||||||
|
private coinProgress = 0;
|
||||||
|
private lastTimestamp: number | null = null;
|
||||||
|
private rafId: number | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// autoBind so methods stay correctly bound when passed as bare references (event handlers,
|
||||||
|
// e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`).
|
||||||
|
makeAutoObservable(this, {}, { autoBind: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get heldTree(): TreeState | undefined {
|
||||||
|
return this.trees.find((tree) => tree.id === this.heldTreeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the character is currently in chopping range of the held tree (as opposed to still walking to it). */
|
||||||
|
get isChopping(): boolean {
|
||||||
|
const tree = this.heldTree;
|
||||||
|
if (!tree)
|
||||||
|
return false;
|
||||||
|
return Math.hypot(tree.x - this.character.x, tree.y - this.character.y) <= INTERACTION_RADIUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the character is actively walking (toward a held tree or a free-form move target). */
|
||||||
|
get isMoving(): boolean {
|
||||||
|
if (this.heldTreeId)
|
||||||
|
return !this.isChopping;
|
||||||
|
if (this.moveTarget)
|
||||||
|
return Math.hypot(this.moveTarget.x - this.character.x, this.moveTarget.y - this.character.y) > 0.5;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (Re)initializes the game for a map of the given size, placing the character in the center and scattering trees. */
|
||||||
|
initialize(width: number, height: number) {
|
||||||
|
this.character = { x: width / 2, y: height / 2 };
|
||||||
|
this.trees = this.spawnTrees(width, height);
|
||||||
|
this.logDrops = [];
|
||||||
|
this.inventory = { logs: 0, coins: 0 };
|
||||||
|
this.heldTreeId = null;
|
||||||
|
this.moveTarget = null;
|
||||||
|
this.chopProgress = 0;
|
||||||
|
this.coinProgress = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnTrees(width: number, height: number): TreeState[] {
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
const trees: TreeState[] = [];
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (trees.length < TREE_COUNT && attempts < 1000) {
|
||||||
|
attempts++;
|
||||||
|
const x = TREE_EDGE_MARGIN + Math.random() * Math.max(0, width - TREE_EDGE_MARGIN * 2);
|
||||||
|
const y = TREE_EDGE_MARGIN + Math.random() * Math.max(0, height - TREE_EDGE_MARGIN * 2);
|
||||||
|
|
||||||
|
if (Math.hypot(x - centerX, y - centerY) < TREE_MIN_DISTANCE_FROM_CENTER)
|
||||||
|
continue;
|
||||||
|
if (trees.some((tree) => Math.hypot(tree.x - x, tree.y - y) < TREE_MIN_DISTANCE_BETWEEN))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
trees.push({ id: randomId('tree'), x, y, logsRemaining: LOGS_PER_TREE });
|
||||||
|
}
|
||||||
|
|
||||||
|
return trees;
|
||||||
|
}
|
||||||
|
|
||||||
|
holdTree(id: string) {
|
||||||
|
if (this.trees.some((tree) => tree.id === id)) {
|
||||||
|
this.moveTarget = null;
|
||||||
|
this.heldTreeId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts walking freely toward an arbitrary point on the map (e.g. a click on empty ground). */
|
||||||
|
moveTo(x: number, y: number) {
|
||||||
|
this.heldTreeId = null;
|
||||||
|
this.chopProgress = 0;
|
||||||
|
this.moveTarget = { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
release() {
|
||||||
|
this.heldTreeId = null;
|
||||||
|
this.moveTarget = null;
|
||||||
|
this.chopProgress = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.rafId !== null)
|
||||||
|
return;
|
||||||
|
this.lastTimestamp = null;
|
||||||
|
|
||||||
|
const loop = (timestamp: number) => {
|
||||||
|
const last = this.lastTimestamp ?? timestamp;
|
||||||
|
// Cap the per-frame step so a stalled/backgrounded tab doesn't cause a huge catch-up jump
|
||||||
|
// (e.g. character teleporting across the map) once it resumes.
|
||||||
|
const deltaTime = Math.min((timestamp - last) / 1000, 0.1);
|
||||||
|
this.lastTimestamp = timestamp;
|
||||||
|
this.tick(deltaTime);
|
||||||
|
this.rafId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
this.rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.rafId !== null) {
|
||||||
|
cancelAnimationFrame(this.rafId);
|
||||||
|
this.rafId = null;
|
||||||
|
}
|
||||||
|
this.lastTimestamp = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick(deltaTime: number) {
|
||||||
|
this.updateMovementAndChopping(deltaTime);
|
||||||
|
this.updateLogFlights(deltaTime);
|
||||||
|
this.updatePickups();
|
||||||
|
this.updateCoinConversion(deltaTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateMovementAndChopping(deltaTime: number) {
|
||||||
|
const tree = this.heldTree;
|
||||||
|
if (tree) {
|
||||||
|
this.walkTowardTree(tree, deltaTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.moveTarget) {
|
||||||
|
this.walkTowardPoint(this.moveTarget, deltaTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chopProgress = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkTowardPoint(target: Vec2, deltaTime: number) {
|
||||||
|
const dx = target.x - this.character.x;
|
||||||
|
const dy = target.y - this.character.y;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
if (distance <= 0.5) {
|
||||||
|
this.character = { x: target.x, y: target.y };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = Math.min(CHARACTER_SPEED * deltaTime, distance);
|
||||||
|
const ratio = step / distance;
|
||||||
|
this.character = {
|
||||||
|
x: this.character.x + dx * ratio,
|
||||||
|
y: this.character.y + dy * ratio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkTowardTree(tree: TreeState, deltaTime: number) {
|
||||||
|
const dx = tree.x - this.character.x;
|
||||||
|
const dy = tree.y - this.character.y;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
// A small tolerance keeps the character from flip-flopping between "walking" and "chopping"
|
||||||
|
// every frame once it's essentially at the interaction radius, which floating-point error on
|
||||||
|
// the two-axis step below can otherwise cause (each flip resets chopProgress, so chopping
|
||||||
|
// could stall forever right at the boundary).
|
||||||
|
if (distance > INTERACTION_RADIUS + 0.5) {
|
||||||
|
const step = Math.min(CHARACTER_SPEED * deltaTime, distance - INTERACTION_RADIUS);
|
||||||
|
const ratio = step / distance;
|
||||||
|
this.character = {
|
||||||
|
x: this.character.x + dx * ratio,
|
||||||
|
y: this.character.y + dy * ratio,
|
||||||
|
};
|
||||||
|
this.chopProgress = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chopProgress += deltaTime;
|
||||||
|
while (this.chopProgress >= CHOP_INTERVAL && tree.logsRemaining > 0) {
|
||||||
|
this.chopProgress -= CHOP_INTERVAL;
|
||||||
|
tree.logsRemaining -= 1;
|
||||||
|
|
||||||
|
// Eject the log on a ballistic arc to a random spot within a circle around the tree; it
|
||||||
|
// only becomes pickable once it lands (see updateLogFlights/updatePickups).
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const landDistance = Math.random() * LOG_EJECT_RADIUS;
|
||||||
|
const toX = tree.x + Math.cos(angle) * landDistance;
|
||||||
|
const toY = tree.y + Math.sin(angle) * landDistance;
|
||||||
|
this.logDrops.push({
|
||||||
|
id: randomId('log'),
|
||||||
|
x: tree.x,
|
||||||
|
y: tree.y,
|
||||||
|
z: 0,
|
||||||
|
phase: 'falling',
|
||||||
|
flight: {
|
||||||
|
fromX: tree.x,
|
||||||
|
fromY: tree.y,
|
||||||
|
toX,
|
||||||
|
toY,
|
||||||
|
elapsed: 0,
|
||||||
|
duration: LOG_FALL_DURATION,
|
||||||
|
arcHeight: LOG_FALL_ARC_HEIGHT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tree.logsRemaining <= 0) {
|
||||||
|
this.trees = this.trees.filter((t) => t.id !== tree.id);
|
||||||
|
this.heldTreeId = null;
|
||||||
|
this.chopProgress = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advances every log currently in a ballistic flight (falling from a tree, or flying into the
|
||||||
|
* inventory once collected), landing/collecting it once its flight completes. Mutates logs in
|
||||||
|
* place and only reassigns the `logDrops` array when one is actually removed (collected) — a
|
||||||
|
* landed log sitting idle, or the array being rebuilt every tick regardless, would otherwise
|
||||||
|
* force every observer of `logDrops` (i.e. the whole map) to re-render every animation frame. */
|
||||||
|
private updateLogFlights(deltaTime: number) {
|
||||||
|
if (this.logDrops.length === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let collectedIds: Set<string> | null = null;
|
||||||
|
for (const log of this.logDrops) {
|
||||||
|
if (log.phase === 'landed')
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const flight = log.flight!;
|
||||||
|
flight.elapsed += deltaTime;
|
||||||
|
const t = Math.min(1, flight.elapsed / flight.duration);
|
||||||
|
log.x = flight.fromX + (flight.toX - flight.fromX) * t;
|
||||||
|
log.y = flight.fromY + (flight.toY - flight.fromY) * t;
|
||||||
|
log.z = flight.arcHeight * 4 * t * (1 - t);
|
||||||
|
|
||||||
|
if (t < 1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (log.phase === 'falling') {
|
||||||
|
log.phase = 'landed';
|
||||||
|
log.flight = undefined;
|
||||||
|
log.z = 0;
|
||||||
|
} else {
|
||||||
|
// Collecting flight finished: the log has reached the character.
|
||||||
|
(collectedIds ??= new Set()).add(log.id);
|
||||||
|
this.inventory.logs += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectedIds)
|
||||||
|
this.logDrops = this.logDrops.filter((log) => !collectedIds!.has(log.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts a landed log flying into the inventory once the character gets close enough. */
|
||||||
|
private updatePickups() {
|
||||||
|
for (const log of this.logDrops) {
|
||||||
|
if (log.phase !== 'landed')
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const distance = Math.hypot(log.x - this.character.x, log.y - this.character.y);
|
||||||
|
if (distance > PICKUP_RADIUS)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
log.phase = 'collecting';
|
||||||
|
log.flight = {
|
||||||
|
fromX: log.x,
|
||||||
|
fromY: log.y,
|
||||||
|
toX: this.character.x,
|
||||||
|
toY: this.character.y,
|
||||||
|
elapsed: 0,
|
||||||
|
duration: LOG_COLLECT_DURATION,
|
||||||
|
arcHeight: LOG_COLLECT_ARC_HEIGHT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCoinConversion(deltaTime: number) {
|
||||||
|
if (this.inventory.logs <= 0) {
|
||||||
|
this.coinProgress = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.coinProgress += deltaTime;
|
||||||
|
while (this.coinProgress >= COIN_INTERVAL && this.inventory.logs > 0) {
|
||||||
|
this.coinProgress -= COIN_INTERVAL;
|
||||||
|
this.inventory.logs -= 1;
|
||||||
|
this.inventory.coins += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const TREE_COUNT = 5;
|
||||||
|
export const LOGS_PER_TREE = 5;
|
||||||
|
|
||||||
|
/** Character movement speed, px/s. */
|
||||||
|
export const CHARACTER_SPEED = 160;
|
||||||
|
|
||||||
|
/** How close the character must get to a tree's center to start chopping it. */
|
||||||
|
export const INTERACTION_RADIUS = 56;
|
||||||
|
|
||||||
|
/** How close a landed log must be to the character to start flying into the inventory. */
|
||||||
|
export const PICKUP_RADIUS = 46;
|
||||||
|
|
||||||
|
/** Radius of the circle around a tree that ejected logs can land in. */
|
||||||
|
export const LOG_EJECT_RADIUS = 70;
|
||||||
|
|
||||||
|
/** Seconds for a log's ballistic flight from the tree to its landing spot. */
|
||||||
|
export const LOG_FALL_DURATION = 0.5;
|
||||||
|
|
||||||
|
/** Peak height (px) of a log's arc while falling from the tree. */
|
||||||
|
export const LOG_FALL_ARC_HEIGHT = 40;
|
||||||
|
|
||||||
|
/** Seconds for a landed log's flight into the character's inventory. */
|
||||||
|
export const LOG_COLLECT_DURATION = 0.25;
|
||||||
|
|
||||||
|
/** Peak height (px) of a log's little hop while flying into the inventory. */
|
||||||
|
export const LOG_COLLECT_ARC_HEIGHT = 16;
|
||||||
|
|
||||||
|
/** Seconds between logs dropping while a tree is held and in range. */
|
||||||
|
export const CHOP_INTERVAL = 0.6;
|
||||||
|
|
||||||
|
/** Seconds for one log in the inventory to turn into one coin. */
|
||||||
|
export const COIN_INTERVAL = 1;
|
||||||
|
|
||||||
|
/** Trees won't spawn closer than this to the character's starting position. */
|
||||||
|
export const TREE_MIN_DISTANCE_FROM_CENTER = 140;
|
||||||
|
|
||||||
|
/** Trees won't spawn closer than this to each other. */
|
||||||
|
export const TREE_MIN_DISTANCE_BETWEEN = 100;
|
||||||
|
|
||||||
|
/** Trees won't spawn within this many px of the map edge. */
|
||||||
|
export const TREE_EDGE_MARGIN = 70;
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/** Pixel grids for 8-bit-style sprites. Each cell is a CSS color, or '' for transparent. */
|
||||||
|
export type PixelGrid = string[][];
|
||||||
|
|
||||||
|
function grid(rows: string[], palette: Record<string, string>): PixelGrid {
|
||||||
|
return rows.map((row) => [...row].map((ch) => palette[ch] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHARACTER_PALETTE = {
|
||||||
|
O: '#1b1611',
|
||||||
|
H: '#4a2f1c',
|
||||||
|
S: '#f0c090',
|
||||||
|
s: '#d9a873',
|
||||||
|
B: '#3f6fb0',
|
||||||
|
b: '#2f5488',
|
||||||
|
P: '#2c2c3e',
|
||||||
|
p: '#20202e',
|
||||||
|
F: '#2a1f14',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHARACTER_SPRITE: PixelGrid = grid(
|
||||||
|
[
|
||||||
|
'....OOOOOOOO....',
|
||||||
|
'..OHHHHHHHHHHO..',
|
||||||
|
'.OHHHHHHHHHHHHO.',
|
||||||
|
'OHHHHHHHHHHHHHHO',
|
||||||
|
'OHHSSSSSSSSSSHHO',
|
||||||
|
'OSSSSOSSSSOSSSSO',
|
||||||
|
'OSSSSSSssSSSSSSO',
|
||||||
|
'.OSSSSSSSSSSSSO.',
|
||||||
|
'......OSSO......',
|
||||||
|
'...OBBBBBBBBO...',
|
||||||
|
'..OBBBBBBBBBBO..',
|
||||||
|
'.OSBBBBBBBBBBSO.',
|
||||||
|
'.OSBBbBBBBbBBSO.',
|
||||||
|
'.OSBBbBBBBbBBSO.',
|
||||||
|
'..OBBbBBBBbBBO..',
|
||||||
|
'..OPPpPPPPpPPO..',
|
||||||
|
'..OPPpPPPPpPPO..',
|
||||||
|
'..OPPpO..OpPPO..',
|
||||||
|
'..OPPpO..OpPPO..',
|
||||||
|
'..OPPpO..OpPPO..',
|
||||||
|
'..OFFFO..OFFFO..',
|
||||||
|
'.OFFFFO..OFFFFO.',
|
||||||
|
],
|
||||||
|
CHARACTER_PALETTE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const TREE_PALETTE = {
|
||||||
|
O: '#14290f',
|
||||||
|
G: '#2e7d32',
|
||||||
|
g: '#1b5e20',
|
||||||
|
H: '#66bb6a',
|
||||||
|
T: '#6b4226',
|
||||||
|
t: '#4e2f1a',
|
||||||
|
n: '#8a5a34',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TREE_SPRITE: PixelGrid = grid(
|
||||||
|
[
|
||||||
|
'......OggO......',
|
||||||
|
'....OGGggGGO....',
|
||||||
|
'..OGGGGggGGGGO..',
|
||||||
|
'.OGgGGGHHGGGgGO.',
|
||||||
|
'OGgGGGGHHGGGGgGO',
|
||||||
|
'OGGgGGGggGGGgGGO',
|
||||||
|
'OGgGGGgGGgGGGgGO',
|
||||||
|
'OGGgGGGGGGGGgGGO',
|
||||||
|
'OGgGGGGggGGGGgGO',
|
||||||
|
'.OGgGGGggGGGgGO.',
|
||||||
|
'..OGgGGggGGgGO..',
|
||||||
|
'...OGgGggGgGO...',
|
||||||
|
'....OGgGGgGO....',
|
||||||
|
'......OggO......',
|
||||||
|
'......OTTO......',
|
||||||
|
'......TTTT......',
|
||||||
|
'......TttT......',
|
||||||
|
'......tnnt......',
|
||||||
|
'......TTTT......',
|
||||||
|
'......TOOT......',
|
||||||
|
],
|
||||||
|
TREE_PALETTE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const LOG_PALETTE = {
|
||||||
|
O: '#2a1a0d',
|
||||||
|
B: '#7a4a2b',
|
||||||
|
C: '#a9703f',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LOG_SPRITE: PixelGrid = grid(
|
||||||
|
[
|
||||||
|
'..OOOOOOOOOOOOOOOO..',
|
||||||
|
'OBBBBBBBBBBBBBBBBBBO',
|
||||||
|
'OBCCCCCCCCCCCCCCCCBO',
|
||||||
|
'OBBBBBBBBBBBBBBBBBBO',
|
||||||
|
'OBCCCCCCCCCCCCCCCCBO',
|
||||||
|
'OBBBBBBBBBBBBBBBBBBO',
|
||||||
|
'OBCCCCCCCCCCCCCCCCBO',
|
||||||
|
'OBBBBBBBBBBBBBBBBBBO',
|
||||||
|
'OBCCCCCCCCCCCCCCCCBO',
|
||||||
|
'OBBBBBBBBBBBBBBBBBBO',
|
||||||
|
'OBCCCCCCCCCCCCCCCCBO',
|
||||||
|
'..OOOOOOOOOOOOOOOO..',
|
||||||
|
],
|
||||||
|
LOG_PALETTE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const COIN_PALETTE = {
|
||||||
|
Y: '#f5c518',
|
||||||
|
D: '#c99a0e',
|
||||||
|
W: '#fff2b0',
|
||||||
|
E: '#a97c08',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COIN_SPRITE: PixelGrid = grid(
|
||||||
|
[
|
||||||
|
'.....YYYYYY.....',
|
||||||
|
'...YYYYYYYYYY...',
|
||||||
|
'..YWWYYYYYYYYY..',
|
||||||
|
'.YYWWYYYYYYYYYY.',
|
||||||
|
'.YYYYYYYYYYYYYY.',
|
||||||
|
'YYYYYDDDDDDYYYYY',
|
||||||
|
'YYYYYDYYYYDYYYYY',
|
||||||
|
'YYYYYDYYYYDYYYYY',
|
||||||
|
'YYYYYDYYYYDYYYYY',
|
||||||
|
'YYYYYDYYYYDYYYYY',
|
||||||
|
'YYYYYDDDDDDYYYYY',
|
||||||
|
'.YYYYYYYYYYYYYY.',
|
||||||
|
'.YYYYYYYYYYEEYY.',
|
||||||
|
'..YYYYYYYYYEEY..',
|
||||||
|
'...YYYYYYYYYY...',
|
||||||
|
'.....YYYYYY.....',
|
||||||
|
],
|
||||||
|
COIN_PALETTE,
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export interface Vec2 {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeState {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
logsRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogPhase = 'falling' | 'landed' | 'collecting';
|
||||||
|
|
||||||
|
export interface LogFlight {
|
||||||
|
fromX: number;
|
||||||
|
fromY: number;
|
||||||
|
toX: number;
|
||||||
|
toY: number;
|
||||||
|
elapsed: number;
|
||||||
|
duration: number;
|
||||||
|
arcHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogDrop {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** Visual height above ground during a flight, for the ballistic arc. 0 once landed. */
|
||||||
|
z: number;
|
||||||
|
phase: LogPhase;
|
||||||
|
flight?: LogFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Inventory {
|
||||||
|
logs: number;
|
||||||
|
coins: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
:root {
|
||||||
|
--panel-bg: #3a2a1e;
|
||||||
|
--panel-border: #1b1611;
|
||||||
|
--text: #f5e9d0;
|
||||||
|
--accent: #f5c518;
|
||||||
|
|
||||||
|
font: 16px/1.4 'Press Start 2P', ui-monospace, Consolas, monospace;
|
||||||
|
color: var(--text);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import path from 'node:path'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
const rootDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const engineDir = path.resolve(rootDir, '../engine')
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
// Follow the node_modules symlink to the real sibling directory, so Vite's dev server
|
||||||
|
// watches and transforms the actual engine source files (not a frozen pre-bundled copy).
|
||||||
|
preserveSymlinks: false,
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
// Treat @incgame/engine as first-party source, not a vendor dependency to pre-bundle once
|
||||||
|
// and cache — otherwise edits to the engine package wouldn't trigger HMR until a full
|
||||||
|
// dev-server restart.
|
||||||
|
exclude: ['@incgame/engine'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
fs: {
|
||||||
|
// The engine package lives outside this project's root (a sibling directory reached
|
||||||
|
// through a node_modules symlink); allow Vite to serve/watch files from there too.
|
||||||
|
allow: [rootDir, engineDir],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
@@ -1,24 +1,20 @@
|
|||||||
import { defaultGame } from "./prefabs/game";
|
import { defaultGame } from "./prefabs/game";
|
||||||
import { Game, serializeGameToString, deserializeGame, deserializeGameFromString } from '@incgame/engine';
|
import { Game, serializeGameToString, deserializeGame, deserializeGameFromString } from '@incgame/engine';
|
||||||
|
|
||||||
export const saveParams = {
|
|
||||||
path: 'default',
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_KEY_GAME = 'incgame';
|
const DEFAULT_KEY_GAME = 'incgame';
|
||||||
|
|
||||||
function getKeyGame(): string {
|
function getLocalStorageKey(): string {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
return params.get('game') || DEFAULT_KEY_GAME;
|
return params.get('game') || DEFAULT_KEY_GAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function save(game: Game) {
|
export function save(game: Game) {
|
||||||
localStorage.setItem(`${saveParams.path}_${getKeyGame()}`, serializeGameToString(game));
|
localStorage.setItem(getLocalStorageKey(), serializeGameToString(game));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function load(): Game {
|
export function load(): Game {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(`${saveParams.path}_${getKeyGame()}`);
|
const raw = localStorage.getItem(getLocalStorageKey());
|
||||||
if (raw)
|
if (raw)
|
||||||
return deserializeGameFromString(raw);
|
return deserializeGameFromString(raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user