@jgengine/core 0.7.0 → 0.8.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 +47 -0
- package/README.md +2 -0
- package/dist/ai/spawnDirector.d.ts +5 -0
- package/dist/ai/spawnDirector.js +13 -1
- package/dist/anim/easing.d.ts +14 -0
- package/dist/anim/easing.js +55 -0
- package/dist/behaviour/behaviour.d.ts +136 -0
- package/dist/behaviour/behaviour.js +388 -0
- package/dist/cards/cardPile.d.ts +1 -0
- package/dist/cards/cardPile.js +12 -3
- package/dist/combat/abilityKit.d.ts +5 -0
- package/dist/combat/abilityKit.js +10 -0
- package/dist/combat/effects.d.ts +1 -1
- package/dist/combat/effects.js +9 -6
- package/dist/combat/projectiles.d.ts +42 -5
- package/dist/combat/projectiles.js +98 -12
- package/dist/combat/telegraph.d.ts +5 -1
- package/dist/combat/telegraph.js +14 -0
- package/dist/commands/commandRegistry.d.ts +1 -1
- package/dist/commands/commandRegistry.js +2 -1
- package/dist/data/dataSource.d.ts +26 -0
- package/dist/data/dataSource.js +93 -0
- package/dist/data/devProxy.d.ts +11 -0
- package/dist/data/devProxy.js +38 -0
- package/dist/data/fetchJson.d.ts +20 -0
- package/dist/data/fetchJson.js +43 -0
- package/dist/data/jsonDataSource.d.ts +4 -0
- package/dist/data/jsonDataSource.js +6 -0
- package/dist/devtools/devtools.d.ts +157 -0
- package/dist/devtools/devtools.js +484 -0
- package/dist/devtools/transformTunables.d.ts +14 -0
- package/dist/devtools/transformTunables.js +38 -0
- package/dist/game/chat.d.ts +66 -0
- package/dist/game/chat.js +165 -0
- package/dist/game/defineGame.d.ts +8 -2
- package/dist/game/defineGame.js +2 -1
- package/dist/game/events.d.ts +25 -0
- package/dist/game/levelSequence.d.ts +44 -0
- package/dist/game/levelSequence.js +78 -0
- package/dist/game/playableGame.d.ts +237 -6
- package/dist/game/playableGame.js +6 -1
- package/dist/game/progression.d.ts +1 -0
- package/dist/game/progression.js +23 -2
- package/dist/game/race.d.ts +5 -0
- package/dist/game/race.js +20 -0
- package/dist/game/social.d.ts +42 -0
- package/dist/game/social.js +122 -0
- package/dist/game/spawnPoints.d.ts +19 -0
- package/dist/game/spawnPoints.js +23 -0
- package/dist/game/worldItem.d.ts +3 -0
- package/dist/game/worldItem.js +12 -6
- package/dist/input/actionBindings.d.ts +10 -0
- package/dist/input/actionBindings.js +14 -0
- package/dist/input/gestureSurface.d.ts +31 -0
- package/dist/input/gestureSurface.js +120 -0
- package/dist/input/pointer.d.ts +45 -0
- package/dist/input/pointer.js +48 -0
- package/dist/input/touchScheme.d.ts +83 -0
- package/dist/input/touchScheme.js +115 -0
- package/dist/item/affix.d.ts +1 -1
- package/dist/item/affix.js +1 -19
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +37 -1
- package/dist/movement/movementModel.d.ts +36 -3
- package/dist/movement/movementModel.js +81 -5
- package/dist/movement/voxelController.d.ts +61 -0
- package/dist/movement/voxelController.js +229 -0
- package/dist/multiplayer/chatContract.d.ts +42 -0
- package/dist/multiplayer/chatContract.js +57 -0
- package/dist/multiplayer/identity.d.ts +13 -0
- package/dist/multiplayer/identity.js +20 -0
- package/dist/multiplayer/poseSyncGate.d.ts +1 -0
- package/dist/multiplayer/poseSyncGate.js +13 -1
- package/dist/multiplayer/presenceModel.d.ts +3 -0
- package/dist/multiplayer/presenceModel.js +15 -1
- package/dist/multiplayer/voiceContract.d.ts +44 -0
- package/dist/multiplayer/voiceContract.js +108 -0
- package/dist/nav/navConstrain.d.ts +14 -0
- package/dist/nav/navConstrain.js +26 -0
- package/dist/nav/navFromEnvironment.d.ts +11 -0
- package/dist/nav/navFromEnvironment.js +16 -0
- package/dist/nav/navGrid.d.ts +10 -0
- package/dist/nav/navGrid.js +18 -1
- package/dist/nav/pathFollow.d.ts +4 -2
- package/dist/nav/pathFollow.js +5 -3
- package/dist/physics/ballisticSweep.d.ts +18 -0
- package/dist/physics/ballisticSweep.js +49 -0
- package/dist/physics/forceVolume.js +5 -5
- package/dist/physics/physicsWorld.d.ts +41 -2
- package/dist/physics/physicsWorld.js +232 -40
- package/dist/physics/spatialGrid.d.ts +1 -1
- package/dist/physics/spatialGrid.js +5 -0
- package/dist/physics/vehicleBody.d.ts +6 -0
- package/dist/physics/vehicleBody.js +16 -17
- package/dist/puzzle/cellGrid.d.ts +26 -0
- package/dist/puzzle/cellGrid.js +110 -0
- package/dist/puzzle/fallingPiece.d.ts +24 -0
- package/dist/puzzle/fallingPiece.js +51 -0
- package/dist/random/nameGen.d.ts +18 -0
- package/dist/random/nameGen.js +35 -0
- package/dist/random/rng.d.ts +2 -0
- package/dist/random/rng.js +23 -0
- package/dist/runtime/adapter.d.ts +35 -0
- package/dist/runtime/adapter.js +37 -1
- package/dist/runtime/cameraDirector.d.ts +10 -0
- package/dist/runtime/cameraDirector.js +25 -0
- package/dist/runtime/gameContext.d.ts +44 -2
- package/dist/runtime/gameContext.js +100 -4
- package/dist/runtime/hostPersistence.d.ts +21 -0
- package/dist/runtime/hostPersistence.js +50 -0
- package/dist/runtime/inputSnapshot.d.ts +7 -0
- package/dist/runtime/inputSnapshot.js +12 -0
- package/dist/runtime/motionIntents.d.ts +25 -0
- package/dist/runtime/motionIntents.js +25 -0
- package/dist/runtime/transport.d.ts +32 -0
- package/dist/scene/entityStore.d.ts +8 -0
- package/dist/scene/entityStore.js +25 -2
- package/dist/scene/movementSpeed.d.ts +35 -0
- package/dist/scene/movementSpeed.js +20 -0
- package/dist/scene/objectQuery.d.ts +18 -0
- package/dist/scene/objectQuery.js +108 -0
- package/dist/scene/objectStore.d.ts +11 -0
- package/dist/scene/objectStore.js +109 -4
- package/dist/scene/paintLayer.d.ts +15 -0
- package/dist/scene/paintLayer.js +53 -0
- package/dist/scene/spatial.d.ts +21 -0
- package/dist/scene/spatial.js +63 -1
- package/dist/sensor/concealment.d.ts +21 -0
- package/dist/sensor/concealment.js +63 -0
- package/dist/sensor/freezeMonitor.d.ts +17 -0
- package/dist/sensor/freezeMonitor.js +35 -0
- package/dist/session/roles.d.ts +6 -0
- package/dist/session/roles.js +32 -0
- package/dist/session/roundState.d.ts +41 -17
- package/dist/session/roundState.js +67 -38
- package/dist/tactics/fallingGrid.d.ts +62 -0
- package/dist/tactics/fallingGrid.js +158 -0
- package/dist/tactics/tacticalGrid.d.ts +11 -0
- package/dist/tactics/tacticalGrid.js +17 -0
- package/dist/time/simClock.d.ts +25 -1
- package/dist/time/simClock.js +28 -3
- package/dist/turn/intent.d.ts +14 -0
- package/dist/turn/intent.js +29 -0
- package/dist/turn/turnLoop.d.ts +2 -0
- package/dist/turn/turnLoop.js +8 -1
- package/dist/world/environmentSummary.d.ts +53 -0
- package/dist/world/environmentSummary.js +129 -0
- package/dist/world/features.d.ts +87 -6
- package/dist/world/features.js +52 -2
- package/dist/world/gridInstances.d.ts +13 -0
- package/dist/world/gridInstances.js +22 -0
- package/dist/world/placementController.d.ts +22 -1
- package/dist/world/placementController.js +56 -0
- package/dist/world/terrain.d.ts +23 -1
- package/dist/world/terrain.js +64 -1
- package/dist/world/voxelField.d.ts +54 -0
- package/dist/world/voxelField.js +241 -0
- package/llms.txt +4102 -0
- package/package.json +3 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
export const DEFAULT_CHAT_RATE_LIMIT = { count: 10, perMs: 10_000 };
|
|
2
|
+
export const DEFAULT_CHAT_HISTORY_LIMIT = 100;
|
|
3
|
+
export const DEFAULT_CHAT_BODY_LENGTH = 500;
|
|
4
|
+
export const DEFAULT_PROXIMITY_CHAT_RADIUS = 20;
|
|
5
|
+
export const WHISPER_CHANNEL_PREFIX = "whisper:";
|
|
6
|
+
export function whisperChannelId(a, b) {
|
|
7
|
+
const [first, second] = a < b ? [a, b] : [b, a];
|
|
8
|
+
return `${WHISPER_CHANNEL_PREFIX}${first}:${second}`;
|
|
9
|
+
}
|
|
10
|
+
export function createChatRateLimiter(limit) {
|
|
11
|
+
const windows = new Map();
|
|
12
|
+
return {
|
|
13
|
+
allow(key, atMs) {
|
|
14
|
+
const cutoff = atMs - limit.perMs;
|
|
15
|
+
const stamps = (windows.get(key) ?? []).filter((stamp) => stamp > cutoff);
|
|
16
|
+
if (stamps.length >= limit.count) {
|
|
17
|
+
windows.set(key, stamps);
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
stamps.push(atMs);
|
|
21
|
+
windows.set(key, stamps);
|
|
22
|
+
return true;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const BUILTIN_CHANNELS = [
|
|
27
|
+
{ id: "global", kind: "global" },
|
|
28
|
+
{ id: "party", kind: "party" },
|
|
29
|
+
{ id: "proximity", kind: "proximity", radius: DEFAULT_PROXIMITY_CHAT_RADIUS },
|
|
30
|
+
];
|
|
31
|
+
export function createChat(deps) {
|
|
32
|
+
const now = deps.now ?? Date.now;
|
|
33
|
+
const events = deps.events;
|
|
34
|
+
const maxBodyLength = deps.maxBodyLength ?? DEFAULT_CHAT_BODY_LENGTH;
|
|
35
|
+
const defaultRateLimit = deps.defaultRateLimit ?? DEFAULT_CHAT_RATE_LIMIT;
|
|
36
|
+
const blockedBy = deps.blockedBy ?? (() => []);
|
|
37
|
+
const channelDefs = new Map();
|
|
38
|
+
for (const def of BUILTIN_CHANNELS)
|
|
39
|
+
channelDefs.set(def.id, { ...def });
|
|
40
|
+
const messagesByChannel = new Map();
|
|
41
|
+
const limiters = new Map();
|
|
42
|
+
let counter = 0;
|
|
43
|
+
function limiterFor(channelId, rateLimit) {
|
|
44
|
+
let limiter = limiters.get(channelId);
|
|
45
|
+
if (limiter === undefined) {
|
|
46
|
+
limiter = createChatRateLimiter(rateLimit);
|
|
47
|
+
limiters.set(channelId, limiter);
|
|
48
|
+
}
|
|
49
|
+
return limiter;
|
|
50
|
+
}
|
|
51
|
+
function hasBlocked(userId, targetUserId) {
|
|
52
|
+
return blockedBy(userId).includes(targetUserId);
|
|
53
|
+
}
|
|
54
|
+
function resolveRecipients(fromUserId, channelId, def) {
|
|
55
|
+
if (channelId.startsWith(WHISPER_CHANNEL_PREFIX)) {
|
|
56
|
+
const pair = channelId.slice(WHISPER_CHANNEL_PREFIX.length).split(":");
|
|
57
|
+
const toUserId = pair.find((userId) => userId !== fromUserId);
|
|
58
|
+
if (pair.length !== 2 || toUserId === undefined || !pair.includes(fromUserId)) {
|
|
59
|
+
return { reason: "not a participant of this whisper" };
|
|
60
|
+
}
|
|
61
|
+
if (hasBlocked(toUserId, fromUserId) || hasBlocked(fromUserId, toUserId)) {
|
|
62
|
+
return { reason: "blocked" };
|
|
63
|
+
}
|
|
64
|
+
return { recipients: [toUserId] };
|
|
65
|
+
}
|
|
66
|
+
if (def === null)
|
|
67
|
+
return { reason: `unknown channel "${channelId}"` };
|
|
68
|
+
if (def.kind === "global")
|
|
69
|
+
return { recipients: "all" };
|
|
70
|
+
if (def.kind === "party") {
|
|
71
|
+
const members = deps.party?.membersOf(fromUserId) ?? [];
|
|
72
|
+
if (!members.includes(fromUserId))
|
|
73
|
+
return { reason: "not in a party" };
|
|
74
|
+
return {
|
|
75
|
+
recipients: members.filter((userId) => userId !== fromUserId && !hasBlocked(userId, fromUserId)),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const proximity = deps.proximity;
|
|
79
|
+
if (proximity === undefined)
|
|
80
|
+
return { reason: "proximity chat not configured" };
|
|
81
|
+
const origin = proximity.entities.get(fromUserId);
|
|
82
|
+
if (origin === null)
|
|
83
|
+
return { reason: `entity "${fromUserId}" is not spawned` };
|
|
84
|
+
const nearby = proximity.spatial.inRadius(origin.position, def.radius ?? DEFAULT_PROXIMITY_CHAT_RADIUS, (id) => id !== fromUserId && proximity.entities.get(id)?.role === "player");
|
|
85
|
+
return { recipients: nearby.filter((userId) => !hasBlocked(userId, fromUserId)) };
|
|
86
|
+
}
|
|
87
|
+
function append(channelId, message, historyLimit) {
|
|
88
|
+
const ring = messagesByChannel.get(channelId) ?? [];
|
|
89
|
+
ring.push(message);
|
|
90
|
+
if (ring.length > historyLimit)
|
|
91
|
+
ring.splice(0, ring.length - historyLimit);
|
|
92
|
+
messagesByChannel.set(channelId, ring);
|
|
93
|
+
}
|
|
94
|
+
function sendResolved(fromUserId, channelId, body, def) {
|
|
95
|
+
const trimmed = body.trim();
|
|
96
|
+
if (trimmed.length === 0)
|
|
97
|
+
return { reason: "empty message" };
|
|
98
|
+
if (trimmed.length > maxBodyLength)
|
|
99
|
+
return { reason: "message too long" };
|
|
100
|
+
const resolved = resolveRecipients(fromUserId, channelId, def);
|
|
101
|
+
if ("reason" in resolved)
|
|
102
|
+
return resolved;
|
|
103
|
+
const at = now();
|
|
104
|
+
const rateLimit = def?.rateLimit ?? defaultRateLimit;
|
|
105
|
+
if (!limiterFor(channelId, rateLimit).allow(`${fromUserId}|${channelId}`, at)) {
|
|
106
|
+
return { reason: "rate limited" };
|
|
107
|
+
}
|
|
108
|
+
counter += 1;
|
|
109
|
+
const message = {
|
|
110
|
+
id: `msg_${counter}`,
|
|
111
|
+
channelId,
|
|
112
|
+
fromUserId,
|
|
113
|
+
body: trimmed,
|
|
114
|
+
at,
|
|
115
|
+
};
|
|
116
|
+
append(channelId, message, def?.historyLimit ?? DEFAULT_CHAT_HISTORY_LIMIT);
|
|
117
|
+
const event = { ...message };
|
|
118
|
+
if (resolved.recipients !== "all")
|
|
119
|
+
event.recipients = resolved.recipients;
|
|
120
|
+
events.emit("chat.message", event);
|
|
121
|
+
return { message, recipients: resolved.recipients };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
register(def) {
|
|
125
|
+
channelDefs.set(def.id, { ...def });
|
|
126
|
+
limiters.delete(def.id);
|
|
127
|
+
},
|
|
128
|
+
channels() {
|
|
129
|
+
return Array.from(channelDefs.values(), (def) => ({ ...def }));
|
|
130
|
+
},
|
|
131
|
+
send(fromUserId, channelId, body) {
|
|
132
|
+
const def = channelDefs.get(channelId) ?? null;
|
|
133
|
+
return sendResolved(fromUserId, channelId, body, def);
|
|
134
|
+
},
|
|
135
|
+
whisper(fromUserId, toUserId, body) {
|
|
136
|
+
if (fromUserId === toUserId)
|
|
137
|
+
return { reason: "cannot whisper yourself" };
|
|
138
|
+
const channelId = whisperChannelId(fromUserId, toUserId);
|
|
139
|
+
return sendResolved(fromUserId, channelId, body, channelDefs.get(channelId) ?? null);
|
|
140
|
+
},
|
|
141
|
+
history(channelId, options) {
|
|
142
|
+
const ring = messagesByChannel.get(channelId) ?? [];
|
|
143
|
+
const viewerUserId = options?.viewerUserId;
|
|
144
|
+
const visible = viewerUserId === undefined
|
|
145
|
+
? ring
|
|
146
|
+
: ring.filter((message) => !hasBlocked(viewerUserId, message.fromUserId));
|
|
147
|
+
const limit = options?.limit;
|
|
148
|
+
return limit === undefined ? visible.slice() : visible.slice(-limit);
|
|
149
|
+
},
|
|
150
|
+
snapshot() {
|
|
151
|
+
const messages = {};
|
|
152
|
+
for (const [channelId, ring] of messagesByChannel) {
|
|
153
|
+
messages[channelId] = ring.map((message) => ({ ...message }));
|
|
154
|
+
}
|
|
155
|
+
return { messages, counter };
|
|
156
|
+
},
|
|
157
|
+
hydrate(data) {
|
|
158
|
+
messagesByChannel.clear();
|
|
159
|
+
for (const [channelId, ring] of Object.entries(data.messages)) {
|
|
160
|
+
messagesByChannel.set(channelId, ring.map((message) => ({ ...message })));
|
|
161
|
+
}
|
|
162
|
+
counter = data.counter;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { ActionCodesMap } from "../input/actionBindings.js";
|
|
2
|
+
import type { GameFeedOptions } from "./feed.js";
|
|
2
3
|
import type { ItemTraits } from "../inventory/inventoryModel.js";
|
|
3
4
|
import type { StorageTier } from "../inventory/storageTier.js";
|
|
4
5
|
import type { SaveConfig } from "../runtime/save.js";
|
|
5
|
-
import type
|
|
6
|
+
import { type AssetCatalog, type ModelAssetRef } from "../scene/assetCatalog.js";
|
|
6
7
|
import { type EntityStore } from "../scene/entityStore.js";
|
|
7
8
|
import type { TimeConfig } from "../time/simClock.js";
|
|
8
9
|
import type { WorldFeature } from "../world/features.js";
|
|
9
10
|
export interface PhysicsConfig {
|
|
10
11
|
gravity?: number;
|
|
12
|
+
jumpVelocity?: number;
|
|
11
13
|
}
|
|
12
14
|
export interface InventoryDeclaration {
|
|
13
15
|
slots: number;
|
|
@@ -35,6 +37,8 @@ export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef,
|
|
|
35
37
|
physics?: PhysicsConfig;
|
|
36
38
|
/** Simulation clock: real→game time scale, selectable speeds, calendar. Exposed as `ctx.time`; the shell feeds its scaled dt to `loop.onTick`. */
|
|
37
39
|
time?: TimeConfig;
|
|
40
|
+
/** Per-action ring-buffer capacity for `ctx.game.feed`. Default 20. */
|
|
41
|
+
feed?: GameFeedOptions;
|
|
38
42
|
inventories?: Record<string, InventoryDeclaration>;
|
|
39
43
|
input?: ActionCodesMap;
|
|
40
44
|
server?: GameServerConfig;
|
|
@@ -42,5 +46,7 @@ export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef,
|
|
|
42
46
|
ui?: unknown;
|
|
43
47
|
loop?: GameLoop<any>;
|
|
44
48
|
}
|
|
45
|
-
export type GameDefinitionConfig<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> = Omit<GameDefinition<TAssetRef, TMultiplayer>, "scene"
|
|
49
|
+
export type GameDefinitionConfig<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> = Omit<GameDefinition<TAssetRef, TMultiplayer>, "scene" | "assets"> & {
|
|
50
|
+
assets?: AssetCatalog<TAssetRef>;
|
|
51
|
+
};
|
|
46
52
|
export declare function defineGame<TAssetRef extends ModelAssetRef, TMultiplayer>(config: GameDefinitionConfig<TAssetRef, TMultiplayer>): GameDefinition<TAssetRef, TMultiplayer>;
|
package/dist/game/defineGame.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { createAssetCatalog } from "../scene/assetCatalog.js";
|
|
1
2
|
import { createEntityStore } from "../scene/entityStore.js";
|
|
2
3
|
export function defineGame(config) {
|
|
3
4
|
if (config.name.trim().length === 0) {
|
|
4
5
|
throw new Error("defineGame: name must be non-empty");
|
|
5
6
|
}
|
|
6
|
-
return { ...config, scene: createEntityStore() };
|
|
7
|
+
return { ...config, scene: createEntityStore(), assets: config.assets ?? createAssetCatalog() };
|
|
7
8
|
}
|
package/dist/game/events.d.ts
CHANGED
|
@@ -63,6 +63,28 @@ export interface SocialPartyLeftEvent {
|
|
|
63
63
|
userId: string;
|
|
64
64
|
partyId: string;
|
|
65
65
|
}
|
|
66
|
+
export interface SocialWorldInvitedEvent {
|
|
67
|
+
inviteId: string;
|
|
68
|
+
fromUserId: string;
|
|
69
|
+
toUserId: string;
|
|
70
|
+
serverId: string;
|
|
71
|
+
joinCode?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface SocialWorldAcceptedEvent {
|
|
74
|
+
inviteId: string;
|
|
75
|
+
userId: string;
|
|
76
|
+
fromUserId: string;
|
|
77
|
+
serverId: string;
|
|
78
|
+
joinCode?: string;
|
|
79
|
+
}
|
|
80
|
+
export interface ChatMessageEvent {
|
|
81
|
+
id: string;
|
|
82
|
+
channelId: string;
|
|
83
|
+
fromUserId: string;
|
|
84
|
+
body: string;
|
|
85
|
+
at: number;
|
|
86
|
+
recipients?: readonly string[];
|
|
87
|
+
}
|
|
66
88
|
export interface StatLevelUpEvent {
|
|
67
89
|
userId: string;
|
|
68
90
|
stat: string;
|
|
@@ -147,6 +169,9 @@ export interface GameEventMap {
|
|
|
147
169
|
"social.friend.added": SocialFriendAddedEvent;
|
|
148
170
|
"social.party.joined": SocialPartyJoinedEvent;
|
|
149
171
|
"social.party.left": SocialPartyLeftEvent;
|
|
172
|
+
"social.world.invited": SocialWorldInvitedEvent;
|
|
173
|
+
"social.world.accepted": SocialWorldAcceptedEvent;
|
|
174
|
+
"chat.message": ChatMessageEvent;
|
|
150
175
|
"stat.levelUp": StatLevelUpEvent;
|
|
151
176
|
"projectile.settled": ProjectileSettledEvent;
|
|
152
177
|
"worldItem.dropped": WorldItemDroppedEvent;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface LevelDescriptor<TLevelConfig> {
|
|
2
|
+
id: string;
|
|
3
|
+
config: TLevelConfig;
|
|
4
|
+
}
|
|
5
|
+
export interface LevelSequenceConfig<TLevelConfig> {
|
|
6
|
+
levels: readonly LevelDescriptor<TLevelConfig>[];
|
|
7
|
+
/** Extra attempts granted after the first per level. Defaults to 0 (a single attempt, no retries). */
|
|
8
|
+
retriesPerLevel?: number;
|
|
9
|
+
}
|
|
10
|
+
export type LevelSequenceStatus = "idle" | "playing" | "cleared" | "failed" | "complete";
|
|
11
|
+
export interface CurrentLevel<TLevelConfig> {
|
|
12
|
+
id: string;
|
|
13
|
+
index: number;
|
|
14
|
+
config: TLevelConfig;
|
|
15
|
+
attempt: number;
|
|
16
|
+
}
|
|
17
|
+
export interface LevelSequenceProgress {
|
|
18
|
+
index: number;
|
|
19
|
+
total: number;
|
|
20
|
+
cleared: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
export interface LevelSequence<TLevelConfig> {
|
|
23
|
+
current(): CurrentLevel<TLevelConfig> | null;
|
|
24
|
+
status(): LevelSequenceStatus;
|
|
25
|
+
/** Enters level 0. A no-op on an empty level list, which leaves `status()` at "idle" forever. */
|
|
26
|
+
start(): void;
|
|
27
|
+
/** Marks the current level cleared; call `advance()` to move to the next one. */
|
|
28
|
+
clear(): void;
|
|
29
|
+
/** Consumes an attempt on the current level. Returns "retry" while attempts remain, else "failed". */
|
|
30
|
+
fail(): "retry" | "failed";
|
|
31
|
+
/** Restarts the current level after a "retry"-eligible failure. Returns false if there was nothing to retry. */
|
|
32
|
+
retry(): boolean;
|
|
33
|
+
/** Moves past a cleared level, or to "complete" if it was the last one. Returns false unless status is "cleared". */
|
|
34
|
+
advance(): boolean;
|
|
35
|
+
reset(): void;
|
|
36
|
+
progress(): LevelSequenceProgress;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A pure, deterministic level campaign: an ordered list of levels, each with its own opaque config, played
|
|
40
|
+
* through a `start` → (`clear` → `advance`)* → `complete` happy path, with `fail`/`retry` handling per-level
|
|
41
|
+
* attempts. Mirrors the reducer style of `game/race.ts` and `ai/spawnDirector.ts` — no I/O, no timers, just
|
|
42
|
+
* state transitions driven by the caller.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createLevelSequence<TLevelConfig>(config: LevelSequenceConfig<TLevelConfig>): LevelSequence<TLevelConfig>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pure, deterministic level campaign: an ordered list of levels, each with its own opaque config, played
|
|
3
|
+
* through a `start` → (`clear` → `advance`)* → `complete` happy path, with `fail`/`retry` handling per-level
|
|
4
|
+
* attempts. Mirrors the reducer style of `game/race.ts` and `ai/spawnDirector.ts` — no I/O, no timers, just
|
|
5
|
+
* state transitions driven by the caller.
|
|
6
|
+
*/
|
|
7
|
+
export function createLevelSequence(config) {
|
|
8
|
+
const levels = config.levels;
|
|
9
|
+
const maxAttempts = 1 + Math.max(0, Math.floor(config.retriesPerLevel ?? 0));
|
|
10
|
+
let index = 0;
|
|
11
|
+
let attempt = 1;
|
|
12
|
+
let status = "idle";
|
|
13
|
+
const clearedIds = [];
|
|
14
|
+
function current() {
|
|
15
|
+
if (status === "idle" || status === "complete")
|
|
16
|
+
return null;
|
|
17
|
+
const level = levels[index];
|
|
18
|
+
if (level === undefined)
|
|
19
|
+
return null;
|
|
20
|
+
return { id: level.id, index, config: level.config, attempt };
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
current,
|
|
24
|
+
status: () => status,
|
|
25
|
+
start() {
|
|
26
|
+
if (levels.length === 0)
|
|
27
|
+
return;
|
|
28
|
+
index = 0;
|
|
29
|
+
attempt = 1;
|
|
30
|
+
clearedIds.length = 0;
|
|
31
|
+
status = "playing";
|
|
32
|
+
},
|
|
33
|
+
clear() {
|
|
34
|
+
if (status !== "playing" || current() === null)
|
|
35
|
+
return;
|
|
36
|
+
clearedIds.push(levels[index].id);
|
|
37
|
+
status = "cleared";
|
|
38
|
+
},
|
|
39
|
+
fail() {
|
|
40
|
+
if (status !== "playing" || current() === null)
|
|
41
|
+
return "failed";
|
|
42
|
+
status = "failed";
|
|
43
|
+
return attempt < maxAttempts ? "retry" : "failed";
|
|
44
|
+
},
|
|
45
|
+
retry() {
|
|
46
|
+
if (status !== "failed" || attempt >= maxAttempts)
|
|
47
|
+
return false;
|
|
48
|
+
attempt += 1;
|
|
49
|
+
status = "playing";
|
|
50
|
+
return true;
|
|
51
|
+
},
|
|
52
|
+
advance() {
|
|
53
|
+
if (status !== "cleared")
|
|
54
|
+
return false;
|
|
55
|
+
if (index >= levels.length - 1) {
|
|
56
|
+
status = "complete";
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
index += 1;
|
|
60
|
+
attempt = 1;
|
|
61
|
+
status = "playing";
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
64
|
+
reset() {
|
|
65
|
+
index = 0;
|
|
66
|
+
attempt = 1;
|
|
67
|
+
clearedIds.length = 0;
|
|
68
|
+
status = "idle";
|
|
69
|
+
},
|
|
70
|
+
progress() {
|
|
71
|
+
return {
|
|
72
|
+
index: status === "complete" ? levels.length : index,
|
|
73
|
+
total: levels.length,
|
|
74
|
+
cleared: [...clearedIds],
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|