@jgengine/core 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/dist/game/feed.d.ts +2 -0
- package/dist/game/feed.js +10 -8
- package/dist/game/progression.d.ts +61 -0
- package/dist/game/progression.js +96 -0
- package/dist/game/quest.d.ts +39 -0
- package/dist/game/quest.js +195 -0
- package/dist/game/trade.d.ts +23 -0
- package/dist/game/trade.js +50 -8
- package/dist/game/unlocks.d.ts +9 -0
- package/dist/game/unlocks.js +19 -1
- package/dist/inventory/slotModel.d.ts +19 -0
- package/dist/inventory/slotModel.js +76 -0
- package/dist/meta/changelog.d.ts +8 -0
- package/dist/meta/changelog.js +25 -0
- package/dist/world/geometry.d.ts +23 -0
- package/dist/world/geometry.js +75 -0
- package/dist/world/interiors.d.ts +35 -0
- package/dist/world/interiors.js +66 -0
- package/dist/world/placement.d.ts +30 -0
- package/dist/world/placement.js +20 -0
- package/package.json +5 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All eight `@jgengine/*` packages are versioned in lockstep, so this one file
|
|
4
|
+
covers every release. Format follows [Keep a Changelog](https://keepachangelog.com);
|
|
5
|
+
each release **leads with a Migrate block** — the concrete steps to move a game
|
|
6
|
+
from the previous version onto the new APIs — because the point of a bump is to
|
|
7
|
+
let consumers pick up the better stuff, not just to list what moved.
|
|
8
|
+
|
|
9
|
+
Agents building on the published SDK can also read this programmatically:
|
|
10
|
+
`import { VERSION, CHANGELOG } from "@jgengine/core/meta/changelog"` gives the
|
|
11
|
+
same data as typed values, so an updater can diff its installed version against
|
|
12
|
+
the latest and surface the migration steps.
|
|
13
|
+
|
|
14
|
+
## Unreleased
|
|
15
|
+
|
|
16
|
+
_Nothing yet._
|
|
17
|
+
|
|
18
|
+
## 0.5.0
|
|
19
|
+
|
|
20
|
+
An additive release: **every 0.4.0 API is unchanged**, so upgrading is only a
|
|
21
|
+
version bump. New pure primitives across progression, inventory slots, world
|
|
22
|
+
geometry, and React store bindings.
|
|
23
|
+
|
|
24
|
+
### Migrate
|
|
25
|
+
|
|
26
|
+
- Bump every `@jgengine/*` dependency to `^0.5.0` (the eight packages version in lockstep).
|
|
27
|
+
- No code change is required — 0.5.0 adds surface, it doesn't move or remove any.
|
|
28
|
+
- Optional: replace a game's hand-rolled `progression/curves.ts` with the new `leveling(...)` track. `leveling({ xpForLevel: { kind: "power", base, exponent, round: "floor" }, maxLevel })` returns `xpForLevel`, `resolve`, and `grantXp(ctx.scene.entity.stats, userId, amount, onLevelUp?)` — a drop-in for the old `xpRequiredForLevel` / `resolveLevelProgress` / `grantXp` exports. `ctx.scene.entity.stats` satisfies the primitive's `LevelingStatAccess` structurally, so no adapter is needed.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `@jgengine/core/game/progression` — genre-agnostic progression primitive. `curve(spec)` / `evalCurve(spec, x)` evaluate declarative scalar curves (`const`, `linear`, `power`, `geometric`, `steps`, `piecewise`, each with optional `round`/`min`/`max`) for speed-by-level, difficulty-by-wave, loot drop-rate ramps, and similar scaling. `leveling(config)` builds the stateful XP→level track (threshold accumulation, multi-level grants, cap handling, `stat.levelUp` emit) on top of an `xpForLevel` curve.
|
|
33
|
+
- `@jgengine/core/inventory/slotModel` — pure slot-grid primitives (`createSlots`, `placeAt`, `removeAt`, `moveSlot`).
|
|
34
|
+
- `@jgengine/core/world/geometry`, `/world/interiors`, `/world/placement` — pure world primitives: grid snapping, footprint AABBs and overlap, interior/exterior spaces, and placement validation.
|
|
35
|
+
- `@jgengine/react/engineStore` — raw-store React bindings (`useEngineState`, `useEngineStore`, `useEngineEvent`).
|
|
36
|
+
- Pure/functional tiers for the `trade`, `unlocks`, `quest`, and `feed` verbs in `@jgengine/core/game`.
|
|
37
|
+
|
|
38
|
+
## 0.4.0
|
|
39
|
+
|
|
40
|
+
Baseline release: the eight `@jgengine/*` packages (core, ws, sql, react,
|
|
41
|
+
convex, node, shell, assets) as the first tracked version. No migration —
|
|
42
|
+
this is the floor changelog entries are measured against.
|
package/dist/game/feed.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface FeedEntry<T = unknown> {
|
|
|
6
6
|
export interface GameFeedOptions {
|
|
7
7
|
limit?: number;
|
|
8
8
|
}
|
|
9
|
+
export declare function appendFeedEntry<T>(buffer: readonly FeedEntry<T>[], entry: FeedEntry<T>, limit: number): FeedEntry<T>[];
|
|
10
|
+
export declare function recentFeedEntries<T>(buffer: readonly FeedEntry<T>[], limit?: number): FeedEntry<T>[];
|
|
9
11
|
export interface GameFeed {
|
|
10
12
|
bind<TName extends keyof GameEventMap>(action: TName, events: GameEvents): () => void;
|
|
11
13
|
push(action: string, entry: unknown): void;
|
package/dist/game/feed.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
export function appendFeedEntry(buffer, entry, limit) {
|
|
2
|
+
const next = [...buffer, entry];
|
|
3
|
+
return next.length > limit ? next.slice(next.length - limit) : next;
|
|
4
|
+
}
|
|
5
|
+
export function recentFeedEntries(buffer, limit) {
|
|
6
|
+
const count = limit ?? buffer.length;
|
|
7
|
+
return buffer.slice(Math.max(0, buffer.length - count));
|
|
8
|
+
}
|
|
1
9
|
export function createGameFeed(options) {
|
|
2
10
|
const limit = options?.limit ?? 20;
|
|
3
11
|
const buffers = new Map();
|
|
4
12
|
const listeners = new Map();
|
|
5
13
|
function append(action, entry) {
|
|
6
|
-
|
|
7
|
-
buffer.push(entry);
|
|
8
|
-
if (buffer.length > limit)
|
|
9
|
-
buffer.splice(0, buffer.length - limit);
|
|
10
|
-
buffers.set(action, buffer);
|
|
14
|
+
buffers.set(action, appendFeedEntry(buffers.get(action) ?? [], entry, limit));
|
|
11
15
|
for (const listener of listeners.get(action) ?? [])
|
|
12
16
|
listener(entry);
|
|
13
17
|
}
|
|
@@ -19,9 +23,7 @@ export function createGameFeed(options) {
|
|
|
19
23
|
append(action, { at: Date.now(), data });
|
|
20
24
|
},
|
|
21
25
|
recent(action, opts) {
|
|
22
|
-
|
|
23
|
-
const count = opts?.limit ?? buffer.length;
|
|
24
|
-
return buffer.slice(Math.max(0, buffer.length - count));
|
|
26
|
+
return recentFeedEntries(buffers.get(action) ?? [], opts?.limit);
|
|
25
27
|
},
|
|
26
28
|
subscribe(action, listener) {
|
|
27
29
|
let set = listeners.get(action);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type CurveDef = {
|
|
2
|
+
kind: "const";
|
|
3
|
+
value: number;
|
|
4
|
+
} | {
|
|
5
|
+
kind: "linear";
|
|
6
|
+
base: number;
|
|
7
|
+
per: number;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "power";
|
|
10
|
+
base: number;
|
|
11
|
+
exponent: number;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "geometric";
|
|
14
|
+
base: number;
|
|
15
|
+
ratio: number;
|
|
16
|
+
} | {
|
|
17
|
+
kind: "steps";
|
|
18
|
+
values: number[];
|
|
19
|
+
} | {
|
|
20
|
+
kind: "piecewise";
|
|
21
|
+
points: [number, number][];
|
|
22
|
+
};
|
|
23
|
+
export interface CurveShape {
|
|
24
|
+
round?: "floor" | "ceil" | "round";
|
|
25
|
+
min?: number;
|
|
26
|
+
max?: number;
|
|
27
|
+
}
|
|
28
|
+
export type Curve = CurveDef & CurveShape;
|
|
29
|
+
export declare function evalCurve(spec: Curve, x: number): number;
|
|
30
|
+
export declare function curve(spec: Curve): (x: number) => number;
|
|
31
|
+
export interface LevelProgress {
|
|
32
|
+
level: number;
|
|
33
|
+
xp: number;
|
|
34
|
+
xpMax: number;
|
|
35
|
+
levelsGained: number;
|
|
36
|
+
}
|
|
37
|
+
export interface LevelingConfig {
|
|
38
|
+
xpForLevel: Curve;
|
|
39
|
+
maxLevel: number;
|
|
40
|
+
startLevel?: number;
|
|
41
|
+
xpStat?: string;
|
|
42
|
+
levelStat?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface LevelingStatAccess {
|
|
45
|
+
get(userId: string, statId: string): {
|
|
46
|
+
current: number;
|
|
47
|
+
max: number;
|
|
48
|
+
} | null;
|
|
49
|
+
set(userId: string, statId: string, patch: {
|
|
50
|
+
current?: number;
|
|
51
|
+
max?: number;
|
|
52
|
+
}): unknown;
|
|
53
|
+
}
|
|
54
|
+
export interface LevelingTrack {
|
|
55
|
+
readonly maxLevel: number;
|
|
56
|
+
readonly startLevel: number;
|
|
57
|
+
xpForLevel(level: number): number;
|
|
58
|
+
resolve(level: number, xp: number): LevelProgress;
|
|
59
|
+
grantXp(access: LevelingStatAccess, userId: string, amount: number, onLevelUp?: (level: number) => void): number;
|
|
60
|
+
}
|
|
61
|
+
export declare function leveling(config: LevelingConfig): LevelingTrack;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
function rawCurve(def, x) {
|
|
2
|
+
switch (def.kind) {
|
|
3
|
+
case "const":
|
|
4
|
+
return def.value;
|
|
5
|
+
case "linear":
|
|
6
|
+
return def.base + def.per * x;
|
|
7
|
+
case "power":
|
|
8
|
+
return def.base * x ** def.exponent;
|
|
9
|
+
case "geometric":
|
|
10
|
+
return def.base * def.ratio ** x;
|
|
11
|
+
case "steps": {
|
|
12
|
+
const i = Math.max(0, Math.min(def.values.length - 1, Math.floor(x)));
|
|
13
|
+
return def.values[i];
|
|
14
|
+
}
|
|
15
|
+
case "piecewise":
|
|
16
|
+
return interpolate(def.points, x);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function interpolate(points, x) {
|
|
20
|
+
if (points.length === 0)
|
|
21
|
+
return 0;
|
|
22
|
+
if (x <= points[0][0])
|
|
23
|
+
return points[0][1];
|
|
24
|
+
const last = points[points.length - 1];
|
|
25
|
+
if (x >= last[0])
|
|
26
|
+
return last[1];
|
|
27
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
28
|
+
const [x1, y1] = points[i];
|
|
29
|
+
if (x <= x1) {
|
|
30
|
+
const [x0, y0] = points[i - 1];
|
|
31
|
+
const span = x1 - x0;
|
|
32
|
+
const t = span === 0 ? 0 : (x - x0) / span;
|
|
33
|
+
return y0 + t * (y1 - y0);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return last[1];
|
|
37
|
+
}
|
|
38
|
+
export function evalCurve(spec, x) {
|
|
39
|
+
let y = rawCurve(spec, x);
|
|
40
|
+
if (spec.min !== undefined)
|
|
41
|
+
y = Math.max(spec.min, y);
|
|
42
|
+
if (spec.max !== undefined)
|
|
43
|
+
y = Math.min(spec.max, y);
|
|
44
|
+
switch (spec.round) {
|
|
45
|
+
case "floor":
|
|
46
|
+
return Math.floor(y);
|
|
47
|
+
case "ceil":
|
|
48
|
+
return Math.ceil(y);
|
|
49
|
+
case "round":
|
|
50
|
+
return Math.round(y);
|
|
51
|
+
default:
|
|
52
|
+
return y;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function curve(spec) {
|
|
56
|
+
return (x) => evalCurve(spec, x);
|
|
57
|
+
}
|
|
58
|
+
export function leveling(config) {
|
|
59
|
+
const maxLevel = config.maxLevel;
|
|
60
|
+
const startLevel = config.startLevel ?? 1;
|
|
61
|
+
const xpStat = config.xpStat ?? "xp";
|
|
62
|
+
const levelStat = config.levelStat ?? "level";
|
|
63
|
+
const xpForLevel = (level) => evalCurve(config.xpForLevel, level);
|
|
64
|
+
const resolve = (level, xp) => {
|
|
65
|
+
let currentLevel = level;
|
|
66
|
+
let currentXp = xp;
|
|
67
|
+
let levelsGained = 0;
|
|
68
|
+
while (currentLevel < maxLevel && currentXp >= xpForLevel(currentLevel)) {
|
|
69
|
+
currentXp -= xpForLevel(currentLevel);
|
|
70
|
+
currentLevel += 1;
|
|
71
|
+
levelsGained += 1;
|
|
72
|
+
}
|
|
73
|
+
if (currentLevel >= maxLevel)
|
|
74
|
+
currentXp = 0;
|
|
75
|
+
return {
|
|
76
|
+
level: currentLevel,
|
|
77
|
+
xp: currentXp,
|
|
78
|
+
xpMax: xpForLevel(Math.min(currentLevel, maxLevel)),
|
|
79
|
+
levelsGained,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const grantXp = (access, userId, amount, onLevelUp) => {
|
|
83
|
+
const xp = access.get(userId, xpStat);
|
|
84
|
+
const level = access.get(userId, levelStat);
|
|
85
|
+
if (xp === null || level === null)
|
|
86
|
+
return 0;
|
|
87
|
+
const progress = resolve(level.current, xp.current + amount);
|
|
88
|
+
access.set(userId, xpStat, { current: progress.xp, max: progress.xpMax });
|
|
89
|
+
if (progress.levelsGained > 0) {
|
|
90
|
+
access.set(userId, levelStat, { current: progress.level });
|
|
91
|
+
onLevelUp?.(progress.level);
|
|
92
|
+
}
|
|
93
|
+
return progress.levelsGained;
|
|
94
|
+
};
|
|
95
|
+
return { maxLevel, startLevel, xpForLevel, resolve, grantXp };
|
|
96
|
+
}
|
package/dist/game/quest.d.ts
CHANGED
|
@@ -91,3 +91,42 @@ export interface QuestJournal {
|
|
|
91
91
|
hydrate(userId: string, data: QuestSnapshotEntry[]): void;
|
|
92
92
|
}
|
|
93
93
|
export declare function createQuestJournal(deps: QuestJournalDeps): QuestJournal;
|
|
94
|
+
export interface QuestAcceptOptions {
|
|
95
|
+
hasUnlock?(id: string): boolean;
|
|
96
|
+
}
|
|
97
|
+
export interface QuestTurnIn {
|
|
98
|
+
state: QuestSnapshotEntry[];
|
|
99
|
+
rewards: QuestRewards | null;
|
|
100
|
+
}
|
|
101
|
+
export interface QuestEvaluator {
|
|
102
|
+
has(questId: string): boolean;
|
|
103
|
+
get(questId: string): QuestDef | null;
|
|
104
|
+
canAccept(state: readonly QuestSnapshotEntry[], questId: string, options?: QuestAcceptOptions): {
|
|
105
|
+
reason: string;
|
|
106
|
+
} | null;
|
|
107
|
+
accept(state: readonly QuestSnapshotEntry[], questId: string, options?: QuestAcceptOptions): QuestSnapshotEntry[] | {
|
|
108
|
+
reason: string;
|
|
109
|
+
};
|
|
110
|
+
abandon(state: readonly QuestSnapshotEntry[], questId: string): QuestSnapshotEntry[];
|
|
111
|
+
progress(state: readonly QuestSnapshotEntry[], questId: string, objectiveId: string, delta: number): QuestSnapshotEntry[];
|
|
112
|
+
canTurnIn(state: readonly QuestSnapshotEntry[], questId: string): {
|
|
113
|
+
reason: string;
|
|
114
|
+
} | null;
|
|
115
|
+
turnIn(state: readonly QuestSnapshotEntry[], questId: string): QuestTurnIn | {
|
|
116
|
+
reason: string;
|
|
117
|
+
};
|
|
118
|
+
grant(state: readonly QuestSnapshotEntry[], questId: string, options?: {
|
|
119
|
+
completed?: boolean;
|
|
120
|
+
}): QuestSnapshotEntry[];
|
|
121
|
+
revoke(state: readonly QuestSnapshotEntry[], questId: string): QuestSnapshotEntry[];
|
|
122
|
+
creditKill(state: readonly QuestSnapshotEntry[], targetCatalogId: string): QuestSnapshotEntry[];
|
|
123
|
+
creditCollect(state: readonly QuestSnapshotEntry[], itemId: string, count: number): QuestSnapshotEntry[];
|
|
124
|
+
list(state: readonly QuestSnapshotEntry[]): QuestInstance[];
|
|
125
|
+
}
|
|
126
|
+
export declare function applyQuestRewards(rewards: QuestRewards, appliers: {
|
|
127
|
+
grantXp?(amount: number): void;
|
|
128
|
+
grantEconomy?(currencyId: string, amount: number): void;
|
|
129
|
+
grantItem?(inventoryId: string, itemId: string, count: number): void;
|
|
130
|
+
grantUnlock?(unlockId: string): void;
|
|
131
|
+
}): void;
|
|
132
|
+
export declare function createQuestEvaluator(defs: QuestDef[] | Record<string, QuestDef>): QuestEvaluator;
|
package/dist/game/quest.js
CHANGED
|
@@ -226,3 +226,198 @@ export function createQuestJournal(deps) {
|
|
|
226
226
|
},
|
|
227
227
|
};
|
|
228
228
|
}
|
|
229
|
+
export function applyQuestRewards(rewards, appliers) {
|
|
230
|
+
if (rewards.xp)
|
|
231
|
+
appliers.grantXp?.(rewards.xp.amount);
|
|
232
|
+
for (const [currencyId, amount] of Object.entries(rewards.economy ?? {})) {
|
|
233
|
+
appliers.grantEconomy?.(currencyId, amount);
|
|
234
|
+
}
|
|
235
|
+
for (const entry of rewards.items ?? []) {
|
|
236
|
+
appliers.grantItem?.(entry.inventory, entry.item, entry.count);
|
|
237
|
+
}
|
|
238
|
+
for (const unlockId of rewards.unlocks ?? []) {
|
|
239
|
+
appliers.grantUnlock?.(unlockId);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function toWorking(state) {
|
|
243
|
+
const working = new Map();
|
|
244
|
+
for (const entry of state) {
|
|
245
|
+
working.set(entry.questId, {
|
|
246
|
+
status: entry.status,
|
|
247
|
+
progress: new Map(Object.entries(entry.progress)),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return working;
|
|
251
|
+
}
|
|
252
|
+
function toSnapshot(working) {
|
|
253
|
+
return Array.from(working, ([questId, state]) => ({
|
|
254
|
+
questId,
|
|
255
|
+
status: state.status,
|
|
256
|
+
progress: Object.fromEntries(state.progress),
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
export function createQuestEvaluator(defs) {
|
|
260
|
+
const catalog = new Map();
|
|
261
|
+
for (const def of Array.isArray(defs) ? defs : Object.values(defs))
|
|
262
|
+
catalog.set(def.id, def);
|
|
263
|
+
function canAcceptWorking(working, questId, options) {
|
|
264
|
+
const def = catalog.get(questId);
|
|
265
|
+
if (def === undefined)
|
|
266
|
+
return { reason: `unknown quest "${questId}"` };
|
|
267
|
+
const state = working.get(questId);
|
|
268
|
+
if (state?.status === "active")
|
|
269
|
+
return { reason: `quest "${questId}" already active` };
|
|
270
|
+
if (state?.status === "completed")
|
|
271
|
+
return { reason: `quest "${questId}" already completed` };
|
|
272
|
+
for (const requirementId of def.requires ?? []) {
|
|
273
|
+
const met = working.get(requirementId)?.status === "completed" ||
|
|
274
|
+
(options?.hasUnlock?.(requirementId) ?? false);
|
|
275
|
+
if (!met)
|
|
276
|
+
return { reason: `quest "${questId}" requires "${requirementId}"` };
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
function progressWorking(working, questId, objectiveId, delta) {
|
|
281
|
+
const def = catalog.get(questId);
|
|
282
|
+
const state = working.get(questId);
|
|
283
|
+
if (def === undefined || state === undefined || state.status !== "active")
|
|
284
|
+
return;
|
|
285
|
+
const objective = def.objectives.find((candidate) => candidate.id === objectiveId);
|
|
286
|
+
if (objective === undefined)
|
|
287
|
+
return;
|
|
288
|
+
const previous = state.progress.get(objectiveId) ?? 0;
|
|
289
|
+
const next = clamp(previous + delta, 0, objective.count);
|
|
290
|
+
if (next !== previous)
|
|
291
|
+
state.progress.set(objectiveId, next);
|
|
292
|
+
}
|
|
293
|
+
function creditWorking(working, predicate, delta) {
|
|
294
|
+
for (const [questId, state] of working) {
|
|
295
|
+
if (state.status !== "active")
|
|
296
|
+
continue;
|
|
297
|
+
const def = catalog.get(questId);
|
|
298
|
+
if (def === undefined)
|
|
299
|
+
continue;
|
|
300
|
+
for (const objective of def.objectives) {
|
|
301
|
+
if (predicate(objective))
|
|
302
|
+
progressWorking(working, questId, objective.id, delta);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function canTurnInWorking(working, questId) {
|
|
307
|
+
const def = catalog.get(questId);
|
|
308
|
+
if (def === undefined)
|
|
309
|
+
return { reason: `unknown quest "${questId}"` };
|
|
310
|
+
const state = working.get(questId);
|
|
311
|
+
if (state === undefined || state.status !== "active") {
|
|
312
|
+
return { reason: `quest "${questId}" is not active` };
|
|
313
|
+
}
|
|
314
|
+
for (const objective of def.objectives) {
|
|
315
|
+
if ((state.progress.get(objective.id) ?? 0) < objective.count) {
|
|
316
|
+
return { reason: `objective "${objective.id}" incomplete` };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
has(questId) {
|
|
323
|
+
return catalog.has(questId);
|
|
324
|
+
},
|
|
325
|
+
get(questId) {
|
|
326
|
+
return catalog.get(questId) ?? null;
|
|
327
|
+
},
|
|
328
|
+
canAccept(state, questId, options) {
|
|
329
|
+
return canAcceptWorking(toWorking(state), questId, options);
|
|
330
|
+
},
|
|
331
|
+
accept(state, questId, options) {
|
|
332
|
+
const working = toWorking(state);
|
|
333
|
+
const denied = canAcceptWorking(working, questId, options);
|
|
334
|
+
if (denied !== null)
|
|
335
|
+
return denied;
|
|
336
|
+
working.set(questId, { status: "active", progress: new Map() });
|
|
337
|
+
return toSnapshot(working);
|
|
338
|
+
},
|
|
339
|
+
abandon(state, questId) {
|
|
340
|
+
const working = toWorking(state);
|
|
341
|
+
if (working.get(questId)?.status === "active")
|
|
342
|
+
working.delete(questId);
|
|
343
|
+
return toSnapshot(working);
|
|
344
|
+
},
|
|
345
|
+
progress(state, questId, objectiveId, delta) {
|
|
346
|
+
const working = toWorking(state);
|
|
347
|
+
progressWorking(working, questId, objectiveId, delta);
|
|
348
|
+
return toSnapshot(working);
|
|
349
|
+
},
|
|
350
|
+
canTurnIn(state, questId) {
|
|
351
|
+
return canTurnInWorking(toWorking(state), questId);
|
|
352
|
+
},
|
|
353
|
+
turnIn(state, questId) {
|
|
354
|
+
const working = toWorking(state);
|
|
355
|
+
const denied = canTurnInWorking(working, questId);
|
|
356
|
+
if (denied !== null)
|
|
357
|
+
return denied;
|
|
358
|
+
const def = catalog.get(questId);
|
|
359
|
+
working.get(questId).status = "completed";
|
|
360
|
+
for (const nextQuestId of def.rewards?.quests ?? []) {
|
|
361
|
+
if (canAcceptWorking(working, nextQuestId) === null) {
|
|
362
|
+
working.set(nextQuestId, { status: "active", progress: new Map() });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return { state: toSnapshot(working), rewards: def.rewards ?? null };
|
|
366
|
+
},
|
|
367
|
+
grant(state, questId, options) {
|
|
368
|
+
const def = catalog.get(questId);
|
|
369
|
+
if (def === undefined)
|
|
370
|
+
return state.slice();
|
|
371
|
+
const working = toWorking(state);
|
|
372
|
+
const completed = options?.completed ?? false;
|
|
373
|
+
const progressMap = new Map();
|
|
374
|
+
if (completed) {
|
|
375
|
+
for (const objective of def.objectives)
|
|
376
|
+
progressMap.set(objective.id, objective.count);
|
|
377
|
+
}
|
|
378
|
+
working.set(questId, {
|
|
379
|
+
status: completed ? "completed" : "active",
|
|
380
|
+
progress: progressMap,
|
|
381
|
+
});
|
|
382
|
+
return toSnapshot(working);
|
|
383
|
+
},
|
|
384
|
+
revoke(state, questId) {
|
|
385
|
+
const working = toWorking(state);
|
|
386
|
+
working.delete(questId);
|
|
387
|
+
return toSnapshot(working);
|
|
388
|
+
},
|
|
389
|
+
creditKill(state, targetCatalogId) {
|
|
390
|
+
const working = toWorking(state);
|
|
391
|
+
creditWorking(working, (objective) => objective.kind === "kill" && objective.target === targetCatalogId, 1);
|
|
392
|
+
return toSnapshot(working);
|
|
393
|
+
},
|
|
394
|
+
creditCollect(state, itemId, count) {
|
|
395
|
+
const working = toWorking(state);
|
|
396
|
+
creditWorking(working, (objective) => objective.kind === "collect" && objective.item === itemId, count);
|
|
397
|
+
return toSnapshot(working);
|
|
398
|
+
},
|
|
399
|
+
list(state) {
|
|
400
|
+
const instances = [];
|
|
401
|
+
for (const entry of state) {
|
|
402
|
+
const def = catalog.get(entry.questId);
|
|
403
|
+
if (def === undefined)
|
|
404
|
+
continue;
|
|
405
|
+
instances.push({
|
|
406
|
+
questId: entry.questId,
|
|
407
|
+
status: entry.status,
|
|
408
|
+
objectives: def.objectives.map((objective) => {
|
|
409
|
+
const current = entry.progress[objective.id] ?? 0;
|
|
410
|
+
return {
|
|
411
|
+
id: objective.id,
|
|
412
|
+
kind: objective.kind,
|
|
413
|
+
count: objective.count,
|
|
414
|
+
progress: current,
|
|
415
|
+
complete: current >= objective.count,
|
|
416
|
+
};
|
|
417
|
+
}),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return instances;
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
package/dist/game/trade.d.ts
CHANGED
|
@@ -32,4 +32,27 @@ export interface TradeSystem {
|
|
|
32
32
|
sell(itemId: string, count: number | undefined, party: TradeParty): TradeRejection | null;
|
|
33
33
|
tradableAt(shopId: string, allItemIds: string[]): string[];
|
|
34
34
|
}
|
|
35
|
+
export declare function canAffordCosts(balances: Record<string, number>, costs: Record<string, number>): string | null;
|
|
36
|
+
export interface TradeOutcome {
|
|
37
|
+
itemId: string;
|
|
38
|
+
count: number;
|
|
39
|
+
currency: Record<string, number>;
|
|
40
|
+
}
|
|
41
|
+
export type TradeResolution = {
|
|
42
|
+
status: "ok";
|
|
43
|
+
outcome: TradeOutcome;
|
|
44
|
+
} | {
|
|
45
|
+
status: "rejected";
|
|
46
|
+
reason: string;
|
|
47
|
+
};
|
|
48
|
+
export declare function resolveBuy(itemId: string, trade: TradeField | null | undefined, shopId: string, count: number, balances: Record<string, number>): TradeResolution;
|
|
49
|
+
export declare function resolveSell(itemId: string, trade: TradeField | null | undefined, count: number): TradeResolution;
|
|
50
|
+
export declare function applyTradeOutcome(outcome: TradeOutcome, appliers: {
|
|
51
|
+
adjustItem(itemId: string, count: number): {
|
|
52
|
+
reason: string;
|
|
53
|
+
} | null;
|
|
54
|
+
adjustCurrency(delta: Record<string, number>): void;
|
|
55
|
+
}): {
|
|
56
|
+
reason: string;
|
|
57
|
+
} | null;
|
|
35
58
|
export declare function createTradeSystem(deps: TradeSystemDeps): TradeSystem;
|
package/dist/game/trade.js
CHANGED
|
@@ -4,21 +4,63 @@ function scale(amounts, count) {
|
|
|
4
4
|
scaled[currency] = amount * count;
|
|
5
5
|
return scaled;
|
|
6
6
|
}
|
|
7
|
+
function negate(amounts) {
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const [currency, amount] of Object.entries(amounts))
|
|
10
|
+
out[currency] = -amount;
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
export function canAffordCosts(balances, costs) {
|
|
14
|
+
for (const [currency, amount] of Object.entries(costs)) {
|
|
15
|
+
if ((balances[currency] ?? 0) < amount)
|
|
16
|
+
return "insufficient-funds";
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function buyEligibility(trade, shopId) {
|
|
21
|
+
if (!trade?.buy)
|
|
22
|
+
return "not-purchasable";
|
|
23
|
+
if (!trade.shops?.includes(shopId))
|
|
24
|
+
return "not-stocked";
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
function sellEligibility(trade) {
|
|
28
|
+
return trade?.sell ? null : "not-sellable";
|
|
29
|
+
}
|
|
30
|
+
export function resolveBuy(itemId, trade, shopId, count, balances) {
|
|
31
|
+
const ineligible = buyEligibility(trade, shopId);
|
|
32
|
+
if (ineligible)
|
|
33
|
+
return { status: "rejected", reason: ineligible };
|
|
34
|
+
const costs = scale(trade.buy, count);
|
|
35
|
+
const unaffordable = canAffordCosts(balances, costs);
|
|
36
|
+
if (unaffordable)
|
|
37
|
+
return { status: "rejected", reason: unaffordable };
|
|
38
|
+
return { status: "ok", outcome: { itemId, count, currency: negate(costs) } };
|
|
39
|
+
}
|
|
40
|
+
export function resolveSell(itemId, trade, count) {
|
|
41
|
+
const ineligible = sellEligibility(trade);
|
|
42
|
+
if (ineligible)
|
|
43
|
+
return { status: "rejected", reason: ineligible };
|
|
44
|
+
return { status: "ok", outcome: { itemId, count: -count, currency: scale(trade.sell, count) } };
|
|
45
|
+
}
|
|
46
|
+
export function applyTradeOutcome(outcome, appliers) {
|
|
47
|
+
const itemResult = appliers.adjustItem(outcome.itemId, outcome.count);
|
|
48
|
+
if (itemResult)
|
|
49
|
+
return itemResult;
|
|
50
|
+
appliers.adjustCurrency(outcome.currency);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
7
53
|
export function createTradeSystem(deps) {
|
|
8
54
|
const { resolveTrade, wallet, inventory } = deps;
|
|
9
55
|
function canBuy(itemId, shopId, count = 1) {
|
|
10
56
|
const trade = resolveTrade(itemId);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return "not-stocked";
|
|
57
|
+
const ineligible = buyEligibility(trade, shopId);
|
|
58
|
+
if (ineligible)
|
|
59
|
+
return ineligible;
|
|
15
60
|
return wallet.canAfford(scale(trade.buy, count));
|
|
16
61
|
}
|
|
17
62
|
function canSell(itemId, _count = 1) {
|
|
18
|
-
|
|
19
|
-
if (!trade?.sell)
|
|
20
|
-
return "not-sellable";
|
|
21
|
-
return null;
|
|
63
|
+
return sellEligibility(resolveTrade(itemId));
|
|
22
64
|
}
|
|
23
65
|
return {
|
|
24
66
|
canBuy,
|
package/dist/game/unlocks.d.ts
CHANGED
|
@@ -2,6 +2,15 @@ export interface UnlockDef {
|
|
|
2
2
|
id: string;
|
|
3
3
|
category?: string;
|
|
4
4
|
}
|
|
5
|
+
export type UnlockState = readonly string[];
|
|
6
|
+
export declare function hasUnlock(granted: UnlockState, unlockId: string): boolean;
|
|
7
|
+
export declare function grantUnlock(granted: UnlockState, unlockId: string): string[];
|
|
8
|
+
export declare function unlockTree(defs: readonly UnlockDef[], categoryId: string): UnlockDef[];
|
|
9
|
+
export interface UnlockCatalog {
|
|
10
|
+
has(unlockId: string): boolean;
|
|
11
|
+
tree(categoryId: string): UnlockDef[];
|
|
12
|
+
}
|
|
13
|
+
export declare function createUnlockCatalog(defs?: readonly UnlockDef[]): UnlockCatalog;
|
|
5
14
|
export interface Unlocks {
|
|
6
15
|
has(userId: string, unlockId: string): boolean;
|
|
7
16
|
grant(userId: string, unlockId: string): void;
|
package/dist/game/unlocks.js
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
export function hasUnlock(granted, unlockId) {
|
|
2
|
+
return granted.includes(unlockId);
|
|
3
|
+
}
|
|
4
|
+
export function grantUnlock(granted, unlockId) {
|
|
5
|
+
return granted.includes(unlockId) ? granted.slice() : [...granted, unlockId];
|
|
6
|
+
}
|
|
7
|
+
export function unlockTree(defs, categoryId) {
|
|
8
|
+
return defs.filter((def) => def.category === categoryId);
|
|
9
|
+
}
|
|
10
|
+
export function createUnlockCatalog(defs = []) {
|
|
11
|
+
const byId = new Map();
|
|
12
|
+
for (const def of defs)
|
|
13
|
+
byId.set(def.id, def);
|
|
14
|
+
return {
|
|
15
|
+
has: (unlockId) => byId.has(unlockId),
|
|
16
|
+
tree: (categoryId) => unlockTree(defs, categoryId),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
1
19
|
export function createUnlocks(defs = []) {
|
|
2
20
|
const definitions = new Map();
|
|
3
21
|
for (const def of defs)
|
|
@@ -22,7 +40,7 @@ export function createUnlocks(defs = []) {
|
|
|
22
40
|
return Array.from(granted.get(userId) ?? []);
|
|
23
41
|
},
|
|
24
42
|
tree(categoryId) {
|
|
25
|
-
return Array.from(definitions.values())
|
|
43
|
+
return unlockTree(Array.from(definitions.values()), categoryId);
|
|
26
44
|
},
|
|
27
45
|
snapshot(userId) {
|
|
28
46
|
return Array.from(granted.get(userId) ?? []);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type Slot<T> = T | null;
|
|
2
|
+
export type SlotGrid<T> = readonly Slot<T>[];
|
|
3
|
+
export type SlotResult<T> = {
|
|
4
|
+
status: "ok";
|
|
5
|
+
grid: Slot<T>[];
|
|
6
|
+
} | {
|
|
7
|
+
status: "rejected";
|
|
8
|
+
reason: "invalid-slot" | "slot-occupied" | "empty-slot";
|
|
9
|
+
};
|
|
10
|
+
export declare function createSlots<T>(size: number): Slot<T>[];
|
|
11
|
+
export declare function normalizeSlots<T>(values: SlotGrid<T>, size: number): Slot<T>[];
|
|
12
|
+
export declare function placeAt<T>(grid: SlotGrid<T>, index: number, value: T): SlotResult<T>;
|
|
13
|
+
export declare function removeAt<T>(grid: SlotGrid<T>, index: number): SlotResult<T>;
|
|
14
|
+
export declare function moveSlot<T>(grid: SlotGrid<T>, from: number, to: number): SlotResult<T>;
|
|
15
|
+
export declare function firstEmpty<T>(grid: SlotGrid<T>): number | null;
|
|
16
|
+
export declare function insertFirst<T>(grid: SlotGrid<T>, value: T): SlotResult<T>;
|
|
17
|
+
export declare function compactSlots<T>(grid: SlotGrid<T>): Slot<T>[];
|
|
18
|
+
export declare function countFilled<T>(grid: SlotGrid<T>): number;
|
|
19
|
+
export declare function indexOfSlot<T>(grid: SlotGrid<T>, match: (value: T, index: number) => boolean): number | null;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export function createSlots(size) {
|
|
2
|
+
return new Array(Math.max(0, size)).fill(null);
|
|
3
|
+
}
|
|
4
|
+
export function normalizeSlots(values, size) {
|
|
5
|
+
const grid = createSlots(size);
|
|
6
|
+
for (let index = 0; index < size; index++)
|
|
7
|
+
grid[index] = values[index] ?? null;
|
|
8
|
+
return grid;
|
|
9
|
+
}
|
|
10
|
+
function inRange(grid, index) {
|
|
11
|
+
return Number.isInteger(index) && index >= 0 && index < grid.length;
|
|
12
|
+
}
|
|
13
|
+
export function placeAt(grid, index, value) {
|
|
14
|
+
if (!inRange(grid, index))
|
|
15
|
+
return { status: "rejected", reason: "invalid-slot" };
|
|
16
|
+
if (grid[index] !== null)
|
|
17
|
+
return { status: "rejected", reason: "slot-occupied" };
|
|
18
|
+
const next = grid.slice();
|
|
19
|
+
next[index] = value;
|
|
20
|
+
return { status: "ok", grid: next };
|
|
21
|
+
}
|
|
22
|
+
export function removeAt(grid, index) {
|
|
23
|
+
if (!inRange(grid, index))
|
|
24
|
+
return { status: "rejected", reason: "invalid-slot" };
|
|
25
|
+
if (grid[index] === null)
|
|
26
|
+
return { status: "rejected", reason: "empty-slot" };
|
|
27
|
+
const next = grid.slice();
|
|
28
|
+
next[index] = null;
|
|
29
|
+
return { status: "ok", grid: next };
|
|
30
|
+
}
|
|
31
|
+
export function moveSlot(grid, from, to) {
|
|
32
|
+
if (!inRange(grid, from) || !inRange(grid, to))
|
|
33
|
+
return { status: "rejected", reason: "invalid-slot" };
|
|
34
|
+
if (grid[from] === null)
|
|
35
|
+
return { status: "rejected", reason: "empty-slot" };
|
|
36
|
+
const next = grid.slice();
|
|
37
|
+
const moved = next[from];
|
|
38
|
+
next[from] = next[to];
|
|
39
|
+
next[to] = moved;
|
|
40
|
+
return { status: "ok", grid: next };
|
|
41
|
+
}
|
|
42
|
+
export function firstEmpty(grid) {
|
|
43
|
+
for (let index = 0; index < grid.length; index++) {
|
|
44
|
+
if (grid[index] === null)
|
|
45
|
+
return index;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
export function insertFirst(grid, value) {
|
|
50
|
+
const index = firstEmpty(grid);
|
|
51
|
+
if (index === null)
|
|
52
|
+
return { status: "rejected", reason: "slot-occupied" };
|
|
53
|
+
return placeAt(grid, index, value);
|
|
54
|
+
}
|
|
55
|
+
export function compactSlots(grid) {
|
|
56
|
+
const filled = grid.filter((slot) => slot !== null);
|
|
57
|
+
const next = createSlots(grid.length);
|
|
58
|
+
for (let index = 0; index < filled.length; index++)
|
|
59
|
+
next[index] = filled[index];
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
export function countFilled(grid) {
|
|
63
|
+
let count = 0;
|
|
64
|
+
for (const slot of grid)
|
|
65
|
+
if (slot !== null)
|
|
66
|
+
count += 1;
|
|
67
|
+
return count;
|
|
68
|
+
}
|
|
69
|
+
export function indexOfSlot(grid, match) {
|
|
70
|
+
for (let index = 0; index < grid.length; index++) {
|
|
71
|
+
const slot = grid[index];
|
|
72
|
+
if (slot !== null && match(slot, index))
|
|
73
|
+
return index;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const VERSION = "0.5.0";
|
|
2
|
+
export const CHANGELOG = {
|
|
3
|
+
"0.5.0": {
|
|
4
|
+
migrate: [
|
|
5
|
+
"Bump every @jgengine/* dependency to ^0.5.0 (the eight packages version in lockstep).",
|
|
6
|
+
"0.5.0 is additive — all 0.4.0 APIs are unchanged, so no code change is required to upgrade.",
|
|
7
|
+
"Optional: replace a game's hand-rolled progression/curves.ts with leveling({ xpForLevel: { kind: 'power', base, exponent, round: 'floor' }, maxLevel }) from @jgengine/core/game/progression; its xpForLevel/resolve/grantXp are drop-ins and ctx.scene.entity.stats satisfies LevelingStatAccess with no adapter.",
|
|
8
|
+
],
|
|
9
|
+
added: [
|
|
10
|
+
"@jgengine/core/game/progression — declarative scalar curves (curve/evalCurve: const, linear, power, geometric, steps, piecewise with round + min/max) and a leveling() XP->level track built on an xpForLevel curve.",
|
|
11
|
+
"@jgengine/core/inventory/slotModel — pure slot-grid primitives (createSlots, placeAt, removeAt, moveSlot).",
|
|
12
|
+
"@jgengine/core/world/geometry, /world/interiors, /world/placement — pure world primitives: grid snapping, footprint AABBs/overlap, interior/exterior spaces, and placement validation.",
|
|
13
|
+
"@jgengine/react/engineStore — raw-store React bindings (useEngineState, useEngineStore, useEngineEvent).",
|
|
14
|
+
"Pure/functional tiers for the trade, unlocks, quest, and feed verbs in @jgengine/core/game.",
|
|
15
|
+
],
|
|
16
|
+
changed: [],
|
|
17
|
+
removed: [],
|
|
18
|
+
},
|
|
19
|
+
"0.4.0": {
|
|
20
|
+
migrate: [],
|
|
21
|
+
added: ["Baseline release: core, ws, sql, react, convex, node, shell, assets."],
|
|
22
|
+
changed: [],
|
|
23
|
+
removed: [],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type Vec2 = readonly [number, number];
|
|
2
|
+
export interface Footprint {
|
|
3
|
+
w: number;
|
|
4
|
+
d: number;
|
|
5
|
+
}
|
|
6
|
+
export interface Aabb {
|
|
7
|
+
minX: number;
|
|
8
|
+
minZ: number;
|
|
9
|
+
maxX: number;
|
|
10
|
+
maxZ: number;
|
|
11
|
+
}
|
|
12
|
+
export interface MoveOptions {
|
|
13
|
+
bounds?: Aabb;
|
|
14
|
+
radius?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function snapToGrid(point: Vec2, size: number): Vec2;
|
|
17
|
+
export declare function footprintAabb(center: Vec2, footprint: Footprint, quarterTurns?: number): Aabb;
|
|
18
|
+
export declare function expandAabb(aabb: Aabb, margin: number): Aabb;
|
|
19
|
+
export declare function aabbOverlap(a: Aabb, b: Aabb): boolean;
|
|
20
|
+
export declare function aabbContains(outer: Aabb, inner: Aabb): boolean;
|
|
21
|
+
export declare function pointInAabb(point: Vec2, aabb: Aabb): boolean;
|
|
22
|
+
export declare function clampToAabb(point: Vec2, aabb: Aabb): Vec2;
|
|
23
|
+
export declare function resolveMove(from: Vec2, delta: Vec2, blockers: readonly Aabb[], options?: MoveOptions): Vec2;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export function snapToGrid(point, size) {
|
|
2
|
+
if (!(size > 0))
|
|
3
|
+
return point;
|
|
4
|
+
return [Math.round(point[0] / size) * size, Math.round(point[1] / size) * size];
|
|
5
|
+
}
|
|
6
|
+
export function footprintAabb(center, footprint, quarterTurns = 0) {
|
|
7
|
+
const turned = ((quarterTurns % 2) + 2) % 2 === 1;
|
|
8
|
+
const halfW = (turned ? footprint.d : footprint.w) / 2;
|
|
9
|
+
const halfD = (turned ? footprint.w : footprint.d) / 2;
|
|
10
|
+
return {
|
|
11
|
+
minX: center[0] - halfW,
|
|
12
|
+
maxX: center[0] + halfW,
|
|
13
|
+
minZ: center[1] - halfD,
|
|
14
|
+
maxZ: center[1] + halfD,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function expandAabb(aabb, margin) {
|
|
18
|
+
return {
|
|
19
|
+
minX: aabb.minX - margin,
|
|
20
|
+
maxX: aabb.maxX + margin,
|
|
21
|
+
minZ: aabb.minZ - margin,
|
|
22
|
+
maxZ: aabb.maxZ + margin,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function aabbOverlap(a, b) {
|
|
26
|
+
return a.minX < b.maxX && a.maxX > b.minX && a.minZ < b.maxZ && a.maxZ > b.minZ;
|
|
27
|
+
}
|
|
28
|
+
export function aabbContains(outer, inner) {
|
|
29
|
+
return (inner.minX >= outer.minX &&
|
|
30
|
+
inner.maxX <= outer.maxX &&
|
|
31
|
+
inner.minZ >= outer.minZ &&
|
|
32
|
+
inner.maxZ <= outer.maxZ);
|
|
33
|
+
}
|
|
34
|
+
export function pointInAabb(point, aabb) {
|
|
35
|
+
return point[0] >= aabb.minX && point[0] <= aabb.maxX && point[1] >= aabb.minZ && point[1] <= aabb.maxZ;
|
|
36
|
+
}
|
|
37
|
+
export function clampToAabb(point, aabb) {
|
|
38
|
+
return [
|
|
39
|
+
Math.min(Math.max(point[0], aabb.minX), aabb.maxX),
|
|
40
|
+
Math.min(Math.max(point[1], aabb.minZ), aabb.maxZ),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
function resolveAxis(main, cross, delta, blockers, axisX) {
|
|
44
|
+
let next = main + delta;
|
|
45
|
+
if (delta === 0)
|
|
46
|
+
return next;
|
|
47
|
+
for (const blocker of blockers) {
|
|
48
|
+
const minMain = axisX ? blocker.minX : blocker.minZ;
|
|
49
|
+
const maxMain = axisX ? blocker.maxX : blocker.maxZ;
|
|
50
|
+
const minCross = axisX ? blocker.minZ : blocker.minX;
|
|
51
|
+
const maxCross = axisX ? blocker.maxZ : blocker.maxX;
|
|
52
|
+
if (cross <= minCross || cross >= maxCross)
|
|
53
|
+
continue;
|
|
54
|
+
if (delta > 0) {
|
|
55
|
+
if (main <= minMain && next > minMain)
|
|
56
|
+
next = minMain;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
if (main >= maxMain && next < maxMain)
|
|
60
|
+
next = maxMain;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return next;
|
|
64
|
+
}
|
|
65
|
+
export function resolveMove(from, delta, blockers, options = {}) {
|
|
66
|
+
const radius = options.radius ?? 0;
|
|
67
|
+
const expanded = radius === 0 ? blockers : blockers.map((blocker) => expandAabb(blocker, radius));
|
|
68
|
+
let x = resolveAxis(from[0], from[1], delta[0], expanded, true);
|
|
69
|
+
if (options.bounds)
|
|
70
|
+
x = Math.min(Math.max(x, options.bounds.minX + radius), options.bounds.maxX - radius);
|
|
71
|
+
let z = resolveAxis(from[1], x, delta[1], expanded, false);
|
|
72
|
+
if (options.bounds)
|
|
73
|
+
z = Math.min(Math.max(z, options.bounds.minZ + radius), options.bounds.maxZ - radius);
|
|
74
|
+
return [x, z];
|
|
75
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Aabb, type Vec2 } from "./geometry.js";
|
|
2
|
+
export type SpaceRef = {
|
|
3
|
+
kind: "exterior";
|
|
4
|
+
} | {
|
|
5
|
+
kind: "interior";
|
|
6
|
+
id: string;
|
|
7
|
+
};
|
|
8
|
+
export interface EntityLocation {
|
|
9
|
+
space: SpaceRef;
|
|
10
|
+
position: Vec2;
|
|
11
|
+
}
|
|
12
|
+
export interface Interior {
|
|
13
|
+
id: string;
|
|
14
|
+
origin: Vec2;
|
|
15
|
+
rotation?: number;
|
|
16
|
+
bounds: Aabb;
|
|
17
|
+
obstacles?: readonly Aabb[];
|
|
18
|
+
}
|
|
19
|
+
export interface Exterior {
|
|
20
|
+
bounds?: Aabb;
|
|
21
|
+
obstacles?: readonly Aabb[];
|
|
22
|
+
}
|
|
23
|
+
export interface InteriorsConfig {
|
|
24
|
+
exterior?: Exterior;
|
|
25
|
+
interiors?: readonly Interior[];
|
|
26
|
+
radius?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface Interiors {
|
|
29
|
+
move(location: EntityLocation, delta: Vec2): EntityLocation;
|
|
30
|
+
enter(location: EntityLocation, id: string): EntityLocation | null;
|
|
31
|
+
leave(location: EntityLocation): EntityLocation | null;
|
|
32
|
+
toInterior(id: string, exterior: Vec2): Vec2 | null;
|
|
33
|
+
toExterior(id: string, interior: Vec2): Vec2 | null;
|
|
34
|
+
}
|
|
35
|
+
export declare function createInteriors(config?: InteriorsConfig): Interiors;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { resolveMove } from "./geometry.js";
|
|
2
|
+
function rotate(point, angle) {
|
|
3
|
+
if (angle === 0)
|
|
4
|
+
return point;
|
|
5
|
+
const cos = Math.cos(angle);
|
|
6
|
+
const sin = Math.sin(angle);
|
|
7
|
+
return [point[0] * cos - point[1] * sin, point[0] * sin + point[1] * cos];
|
|
8
|
+
}
|
|
9
|
+
function toInteriorFrame(interior, exterior) {
|
|
10
|
+
const local = [exterior[0] - interior.origin[0], exterior[1] - interior.origin[1]];
|
|
11
|
+
return rotate(local, -(interior.rotation ?? 0));
|
|
12
|
+
}
|
|
13
|
+
function toExteriorFrame(interior, local) {
|
|
14
|
+
const rotated = rotate(local, interior.rotation ?? 0);
|
|
15
|
+
return [rotated[0] + interior.origin[0], rotated[1] + interior.origin[1]];
|
|
16
|
+
}
|
|
17
|
+
export function createInteriors(config = {}) {
|
|
18
|
+
const radius = config.radius ?? 0;
|
|
19
|
+
const exterior = config.exterior ?? {};
|
|
20
|
+
const byId = new Map();
|
|
21
|
+
for (const interior of config.interiors ?? [])
|
|
22
|
+
byId.set(interior.id, interior);
|
|
23
|
+
return {
|
|
24
|
+
move(location, delta) {
|
|
25
|
+
if (location.space.kind === "exterior") {
|
|
26
|
+
const position = resolveMove(location.position, delta, exterior.obstacles ?? [], {
|
|
27
|
+
bounds: exterior.bounds,
|
|
28
|
+
radius,
|
|
29
|
+
});
|
|
30
|
+
return { space: { kind: "exterior" }, position };
|
|
31
|
+
}
|
|
32
|
+
const interior = byId.get(location.space.id);
|
|
33
|
+
if (interior === undefined)
|
|
34
|
+
return location;
|
|
35
|
+
const position = resolveMove(location.position, delta, interior.obstacles ?? [], {
|
|
36
|
+
bounds: interior.bounds,
|
|
37
|
+
radius,
|
|
38
|
+
});
|
|
39
|
+
return { space: { kind: "interior", id: interior.id }, position };
|
|
40
|
+
},
|
|
41
|
+
enter(location, id) {
|
|
42
|
+
if (location.space.kind !== "exterior")
|
|
43
|
+
return null;
|
|
44
|
+
const interior = byId.get(id);
|
|
45
|
+
if (interior === undefined)
|
|
46
|
+
return null;
|
|
47
|
+
return { space: { kind: "interior", id }, position: toInteriorFrame(interior, location.position) };
|
|
48
|
+
},
|
|
49
|
+
leave(location) {
|
|
50
|
+
if (location.space.kind !== "interior")
|
|
51
|
+
return null;
|
|
52
|
+
const interior = byId.get(location.space.id);
|
|
53
|
+
if (interior === undefined)
|
|
54
|
+
return null;
|
|
55
|
+
return { space: { kind: "exterior" }, position: toExteriorFrame(interior, location.position) };
|
|
56
|
+
},
|
|
57
|
+
toInterior(id, exterior) {
|
|
58
|
+
const interior = byId.get(id);
|
|
59
|
+
return interior === undefined ? null : toInteriorFrame(interior, exterior);
|
|
60
|
+
},
|
|
61
|
+
toExterior(id, interior) {
|
|
62
|
+
const frame = byId.get(id);
|
|
63
|
+
return frame === undefined ? null : toExteriorFrame(frame, interior);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Aabb, type Footprint, type Vec2 } from "./geometry.js";
|
|
2
|
+
export interface PlacementObstacle {
|
|
3
|
+
aabb: Aabb;
|
|
4
|
+
id?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface PlacementRequest {
|
|
7
|
+
center: Vec2;
|
|
8
|
+
footprint: Footprint;
|
|
9
|
+
quarterTurns?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface PlacementRules {
|
|
12
|
+
bounds?: Aabb;
|
|
13
|
+
obstacles?: readonly PlacementObstacle[];
|
|
14
|
+
snap?: number;
|
|
15
|
+
}
|
|
16
|
+
export type PlacementResult = {
|
|
17
|
+
status: "ok";
|
|
18
|
+
center: Vec2;
|
|
19
|
+
aabb: Aabb;
|
|
20
|
+
} | {
|
|
21
|
+
status: "rejected";
|
|
22
|
+
reason: "out-of-bounds";
|
|
23
|
+
} | {
|
|
24
|
+
status: "rejected";
|
|
25
|
+
reason: "overlap";
|
|
26
|
+
obstacle: PlacementObstacle;
|
|
27
|
+
index: number;
|
|
28
|
+
};
|
|
29
|
+
export declare function validatePlacement(request: PlacementRequest, rules?: PlacementRules): PlacementResult;
|
|
30
|
+
export declare function footprintObstacle(request: PlacementRequest, id?: string): PlacementObstacle;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { aabbContains, aabbOverlap, footprintAabb, snapToGrid, } from "./geometry.js";
|
|
2
|
+
export function validatePlacement(request, rules = {}) {
|
|
3
|
+
const center = rules.snap === undefined ? request.center : snapToGrid(request.center, rules.snap);
|
|
4
|
+
const aabb = footprintAabb(center, request.footprint, request.quarterTurns ?? 0);
|
|
5
|
+
if (rules.bounds !== undefined && !aabbContains(rules.bounds, aabb)) {
|
|
6
|
+
return { status: "rejected", reason: "out-of-bounds" };
|
|
7
|
+
}
|
|
8
|
+
const obstacles = rules.obstacles ?? [];
|
|
9
|
+
for (let index = 0; index < obstacles.length; index++) {
|
|
10
|
+
const obstacle = obstacles[index];
|
|
11
|
+
if (aabbOverlap(aabb, obstacle.aabb)) {
|
|
12
|
+
return { status: "rejected", reason: "overlap", obstacle, index };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return { status: "ok", center, aabb };
|
|
16
|
+
}
|
|
17
|
+
export function footprintObstacle(request, id) {
|
|
18
|
+
const aabb = footprintAabb(request.center, request.footprint, request.quarterTurns ?? 0);
|
|
19
|
+
return id === undefined ? { aabb } : { aabb, id };
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Genre-agnostic TypeScript game engine SDK: game runtime, state store, entity scene, combat, movement, inventory, economy, quests, multiplayer primitives.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
"url": "git+https://github.com/Noisemaker111/jgengine.git",
|
|
11
11
|
"directory": "packages/core"
|
|
12
12
|
},
|
|
13
|
-
"files": [
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"CHANGELOG.md"
|
|
16
|
+
],
|
|
14
17
|
"exports": {
|
|
15
18
|
".": {
|
|
16
19
|
"types": "./dist/index.d.ts",
|