@jgengine/shell 0.11.0 → 0.12.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/CombatPresentation.d.ts +5 -0
  3. package/dist/CombatPresentation.js +8 -0
  4. package/dist/GameHost.d.ts +25 -1
  5. package/dist/GameHost.js +60 -4
  6. package/dist/GamePlayer.d.ts +5 -0
  7. package/dist/GamePlayer.js +5 -0
  8. package/dist/GamePlayerShell.d.ts +5 -0
  9. package/dist/GamePlayerShell.js +48 -358
  10. package/dist/GameUiPreview.js +3 -0
  11. package/dist/Shell3dPresentation.d.ts +63 -0
  12. package/dist/Shell3dPresentation.js +302 -0
  13. package/dist/ShellChrome.d.ts +51 -0
  14. package/dist/ShellChrome.js +51 -0
  15. package/dist/ShellHudPresentation.d.ts +41 -0
  16. package/dist/ShellHudPresentation.js +22 -0
  17. package/dist/audio/audioEngine.d.ts +4 -0
  18. package/dist/audio/audioEngine.js +111 -26
  19. package/dist/audio/audioWire.d.ts +22 -4
  20. package/dist/audio/audioWire.js +46 -3
  21. package/dist/audio/loopParams.d.ts +20 -0
  22. package/dist/audio/loopParams.js +28 -0
  23. package/dist/camera/GameCameraRig.js +6 -1
  24. package/dist/camera/cameraRigs.d.ts +3 -1
  25. package/dist/camera/cameraRigs.js +21 -3
  26. package/dist/camera/rigMath.d.ts +20 -0
  27. package/dist/camera/rigMath.js +29 -0
  28. package/dist/defineGame.d.ts +15 -1
  29. package/dist/defineGame.js +26 -4
  30. package/dist/devtools/DevtoolsOverlay.d.ts +4 -4
  31. package/dist/devtools/DevtoolsOverlay.js +28 -12
  32. package/dist/devtools/agentBridge.js +4 -0
  33. package/dist/devtools/perfDiagnose.js +1 -1
  34. package/dist/diagnostics/RuntimeDiagnostics.d.ts +7 -0
  35. package/dist/diagnostics/RuntimeDiagnostics.js +23 -12
  36. package/dist/environment/EnvironmentScene.js +12 -3
  37. package/dist/environment/terrainGroundColor.d.ts +9 -0
  38. package/dist/environment/terrainGroundColor.js +22 -0
  39. package/dist/gameKit.d.ts +27 -0
  40. package/dist/gameKit.js +29 -0
  41. package/dist/map/MapMarkerBeacons.d.ts +3 -2
  42. package/dist/postfx/PostProcessing.d.ts +9 -1
  43. package/dist/postfx/PostProcessing.js +11 -4
  44. package/dist/presentationResolve.d.ts +35 -0
  45. package/dist/presentationResolve.js +51 -0
  46. package/dist/registry.d.ts +1 -0
  47. package/dist/render/assetBase.d.ts +17 -0
  48. package/dist/render/assetBase.js +35 -0
  49. package/dist/render/modelLoad.js +4 -1
  50. package/dist/render/resolveModel.d.ts +15 -0
  51. package/dist/render/resolveModel.js +37 -2
  52. package/dist/scatter/InstancedScatter.js +5 -0
  53. package/dist/scatter/scatterProxies.js +24 -0
  54. package/dist/scene/AuthoredScene.d.ts +3 -1
  55. package/dist/scene/AuthoredScene.js +7 -2
  56. package/dist/scene/CityRenderer.d.ts +2 -0
  57. package/dist/scene/CityRenderer.js +1031 -0
  58. package/dist/scene/GrassFieldRenderer.js +53 -5
  59. package/dist/scene/PoleLineRenderer.d.ts +2 -0
  60. package/dist/scene/PoleLineRenderer.js +287 -0
  61. package/dist/scene/SoilPatchRenderer.js +70 -7
  62. package/dist/scene/WaterRenderer.js +34 -10
  63. package/dist/scene/builtinSceneKindRenderers.js +4 -0
  64. package/dist/scene/cityWindowMaterial.d.ts +16 -0
  65. package/dist/scene/cityWindowMaterial.js +81 -0
  66. package/dist/scene/sceneKindRenderers.d.ts +2 -0
  67. package/dist/settings/appliedSettings.d.ts +2 -1
  68. package/dist/settings/appliedSettings.js +4 -2
  69. package/dist/structures/TransformGizmo.d.ts +11 -1
  70. package/dist/structures/TransformGizmo.js +9 -5
  71. package/dist/terrain/GrassField.d.ts +11 -3
  72. package/dist/terrain/GrassField.js +16 -6
  73. package/dist/terrain/grassGeometry.d.ts +33 -1
  74. package/dist/terrain/grassGeometry.js +83 -21
  75. package/dist/terrain/grassMaterial.d.ts +18 -0
  76. package/dist/terrain/grassMaterial.js +66 -13
  77. package/dist/terrain/heightfieldGeometry.d.ts +34 -0
  78. package/dist/terrain/heightfieldGeometry.js +109 -0
  79. package/dist/terrain/index.d.ts +1 -0
  80. package/dist/terrain/index.js +1 -0
  81. package/dist/terrain/soilPatchMaterial.d.ts +15 -2
  82. package/dist/terrain/soilPatchMaterial.js +46 -8
  83. package/dist/terrain/terrainDetailMaterial.js +15 -2
  84. package/dist/water/Ocean.d.ts +7 -1
  85. package/dist/water/Ocean.js +16 -3
  86. package/dist/water/OceanConfig.d.ts +9 -0
  87. package/dist/water/OceanConfig.js +6 -0
  88. package/dist/water/OceanMaterial.d.ts +12 -0
  89. package/dist/water/OceanMaterial.js +9 -0
  90. package/dist/water/OceanShader.d.ts +2 -2
  91. package/dist/water/OceanShader.js +78 -10
  92. package/dist/world/PlaceScene.d.ts +11 -0
  93. package/dist/world/PlaceScene.js +27 -0
  94. package/dist/world/WorldScene.js +29 -2
  95. package/dist/worldSky.d.ts +7 -1
  96. package/dist/worldSky.js +11 -1
  97. package/package.json +4 -4
  98. package/skills/game-design/SKILL.md +28 -0
  99. package/skills/game-design/agents/openai.yaml +4 -0
  100. package/skills/game-design/references/application-playbook.md +170 -0
  101. package/skills/game-design/references/field-guide.md +288 -0
  102. package/skills/jgengine/SKILL.md +7 -4
  103. package/skills/jgengine/api.md +172 -79
  104. package/skills/jgengine/capabilities.md +104 -2
  105. package/skills/jgengine/recipes/minimal-game.md +88 -0
  106. package/skills/jgengine-ui/SKILL.md +15 -10
  107. package/skills/jgengine-ui/api.md +202 -47
  108. package/skills/jgengine-ui/capabilities.md +71 -2
  109. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  110. package/skills/jgengine-ui/reference.md +20 -7
  111. package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
  112. package/skills/jgengine-world/SKILL.md +13 -0
  113. package/skills/jgengine-world/api.md +419 -15
  114. package/skills/jgengine-world/capabilities.md +108 -2
  115. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  116. package/skills/jgengine-world/reference.md +43 -4
  117. package/skills/level-design/SKILL.md +54 -0
  118. package/skills/level-design/agents/openai.yaml +4 -0
  119. package/skills/level-design/references/application-playbook.md +192 -0
  120. package/skills/level-design/references/field-guide.md +337 -0
  121. package/skills/level-design/references/live-editor-loop.md +179 -0
  122. package/dist/cartridge/runtime.d.ts +0 -15
  123. package/dist/cartridge/runtime.js +0 -556
  124. package/dist/cartridge/spec.d.ts +0 -188
  125. package/dist/cartridge/spec.js +0 -19
  126. package/dist/cartridge/validate.d.ts +0 -2
  127. package/dist/cartridge/validate.js +0 -78
  128. package/dist/cartridge.d.ts +0 -145
  129. package/dist/cartridge.js +0 -246
  130. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -1,556 +0,0 @@
1
- import { advanceSpawnDirector, createSpawnDirectorState } from "@jgengine/core/ai/spawnDirector";
2
- import { createAbilityKit } from "@jgengine/core/combat/abilityKit";
3
- import { setGamePhase } from "@jgengine/core/game/gamePhase";
4
- import { leveling } from "@jgengine/core/game/progression";
5
- import { createRunDraft } from "@jgengine/core/game/runDraft";
6
- import { aimToPoint } from "@jgengine/core/input/pointer";
7
- import { seededRng } from "@jgengine/core/item/affix";
8
- import { selectAutoTarget } from "@jgengine/core/scene/autoTarget";
9
- import { HIT_EFFECT, XP_GEM_BASE_TYPE, leveled, } from "./spec.js";
10
- function gemRarity(spec, value) {
11
- for (const [threshold, rarity] of spec.xpGems.rarityThresholds) {
12
- if (value >= threshold)
13
- return rarity;
14
- }
15
- return spec.xpGems.defaultRarity;
16
- }
17
- function facing(fromX, fromZ, toX, toZ) {
18
- return Math.atan2(toX - fromX, toZ - fromZ);
19
- }
20
- function buildKit(spec, levels) {
21
- return createAbilityKit(Object.entries(spec.weapons).map(([id, weapon]) => ({
22
- id,
23
- cooldownMs: leveled(weapon.cooldownMs, levels[id] ?? 1),
24
- })));
25
- }
26
- function compileEntities(spec) {
27
- const out = {
28
- [spec.player.kind]: {
29
- role: "player",
30
- movement: { walkSpeed: spec.player.walkSpeed },
31
- stats: {
32
- health: { max: spec.player.health, min: 0 },
33
- xp: { max: 1, min: 0, current: 0 },
34
- level: { max: spec.progression.maxLevel, min: 1, current: 1 },
35
- },
36
- receive: { hit: { order: ["health"] } },
37
- },
38
- };
39
- for (const [id, enemy] of Object.entries(spec.enemies)) {
40
- out[id] = {
41
- role: "enemy",
42
- movement: { walkSpeed: enemy.walkSpeed },
43
- stats: { health: { max: enemy.health, min: 0 } },
44
- receive: { hit: { order: ["health"] } },
45
- };
46
- }
47
- return out;
48
- }
49
- export function createCartridge(spec) {
50
- const track = leveling({ xpForLevel: spec.progression.xp, maxLevel: spec.progression.maxLevel });
51
- const entities = compileEntities(spec);
52
- const runs = new WeakMap();
53
- function initialPhase() {
54
- if (spec.flow?.start === "gate")
55
- return "start";
56
- if ((spec.flow?.countdownSeconds ?? 0) > 0)
57
- return "countdown";
58
- return "playing";
59
- }
60
- function seededState() {
61
- const seed = spec.seed ?? "cartridge";
62
- const weaponLevels = {};
63
- for (const id of Object.keys(spec.weapons))
64
- weaponLevels[id] = 1;
65
- const offers = spec.progression.draft.upgrades.map((upgrade) => ({
66
- id: upgrade.id,
67
- label: upgrade.label,
68
- weight: upgrade.weight,
69
- maxStacks: upgrade.maxStacks,
70
- }));
71
- return {
72
- rng: seededRng(seed),
73
- spawn: createSpawnDirectorState(spec.spawning.director),
74
- weaponLevels,
75
- kit: buildKit(spec, weaponLevels),
76
- fields: { ...spec.fields },
77
- enemyNextHitAt: new Map(),
78
- phase: initialPhase(),
79
- playingSeconds: 0,
80
- countdownRemaining: spec.flow?.countdownSeconds ?? 0,
81
- kills: 0,
82
- levelUpQueue: 0,
83
- draft: createRunDraft({ offers, rng: seededRng(`${seed}-draft`) }),
84
- pendingOffers: null,
85
- bolts: [],
86
- pulses: [],
87
- fxCounter: 0,
88
- };
89
- }
90
- function createRun() {
91
- const run = {
92
- ...seededState(),
93
- listeners: new Set(),
94
- view: {
95
- get phase() {
96
- return run.phase;
97
- },
98
- get playingSeconds() {
99
- return run.playingSeconds;
100
- },
101
- get countdownRemaining() {
102
- return run.countdownRemaining;
103
- },
104
- get kills() {
105
- return run.kills;
106
- },
107
- weaponLevel: (weaponId) => run.weaponLevels[weaponId] ?? 1,
108
- field: (name) => run.fields[name] ?? 0,
109
- get pendingOffers() {
110
- if (run.pendingOffers === null)
111
- return null;
112
- return run.pendingOffers.map((offer) => ({ id: offer.id, label: offer.label ?? offer.id }));
113
- },
114
- get bolts() {
115
- return run.bolts;
116
- },
117
- get pulses() {
118
- return run.pulses;
119
- },
120
- subscribe(listener) {
121
- run.listeners.add(listener);
122
- return () => run.listeners.delete(listener);
123
- },
124
- },
125
- };
126
- return run;
127
- }
128
- function getRun(ctx) {
129
- let existing = runs.get(ctx);
130
- if (existing === undefined) {
131
- existing = createRun();
132
- runs.set(ctx, existing);
133
- }
134
- return existing;
135
- }
136
- function notify(run) {
137
- for (const listener of run.listeners)
138
- listener();
139
- }
140
- function syncPhase(ctx, run) {
141
- setGamePhase(ctx, run.phase === "playing" ? "playing" : run.phase === "won" || run.phase === "lost" ? "ended" : "menu");
142
- }
143
- function damageMultiplier(run) {
144
- return run.fields["damageMultiplier"] ?? 1;
145
- }
146
- function spawnPoint(ctx, run) {
147
- const placement = spec.spawning.placement;
148
- if (placement.kind === "custom")
149
- return placement.position(ctx, run.view);
150
- const angle = run.rng() * Math.PI * 2;
151
- const x = Math.cos(angle) * placement.radius;
152
- const z = Math.sin(angle) * placement.radius;
153
- return [x, ctx.world.groundHeightAt(x, z), z];
154
- }
155
- function advanceSpawning(ctx, run, dt) {
156
- const alive = ctx.scene.entity.list().filter((entity) => spec.enemies[entity.name] !== undefined).length;
157
- const step = advanceSpawnDirector(spec.spawning.director, run.spawn, dt, { alive, players: 1 });
158
- run.spawn = step.state;
159
- for (const request of step.spawns) {
160
- if (spec.enemies[request.entryId] === undefined)
161
- continue;
162
- ctx.scene.entity.spawn(request.entryId, { position: spawnPoint(ctx, run) });
163
- }
164
- }
165
- function advanceEnemies(ctx, run, dt) {
166
- const playerId = ctx.player.userId;
167
- const player = ctx.scene.entity.get(playerId);
168
- if (player === null)
169
- return;
170
- const now = ctx.time.now();
171
- for (const enemy of ctx.scene.entity.list()) {
172
- const def = spec.enemies[enemy.name];
173
- if (def === undefined)
174
- continue;
175
- const next = def.behavior === "none" ? null : ctx.scene.entity.moveToward(enemy.id, playerId, { speed: def.walkSpeed, dt });
176
- if (next !== null) {
177
- const grounded = [
178
- next[0],
179
- ctx.world.groundHeightAt(next[0], next[2]),
180
- next[2],
181
- ];
182
- ctx.scene.entity.setPose(enemy.id, {
183
- position: grounded,
184
- rotationY: facing(enemy.position[0], enemy.position[2], player.position[0], player.position[2]),
185
- });
186
- }
187
- const distance = ctx.scene.entity.distance(enemy.id, playerId);
188
- if (distance === null || distance > spec.combat.contactRadius)
189
- continue;
190
- const readyAt = run.enemyNextHitAt.get(enemy.id) ?? 0;
191
- if (now < readyAt)
192
- continue;
193
- run.enemyNextHitAt.set(enemy.id, now + def.contact.intervalSeconds);
194
- ctx.scene.entity.effect({ from: enemy.id, to: playerId, effect: HIT_EFFECT, via: { amount: def.contact.damage } });
195
- }
196
- }
197
- function enemyIds(ctx) {
198
- return ctx.scene.entity
199
- .list()
200
- .filter((entity) => spec.enemies[entity.name] !== undefined)
201
- .map((entity) => entity.id);
202
- }
203
- function fireProjectile(ctx, run, weaponId, weapon, playerId, playerPos, enemies) {
204
- const targetId = selectAutoTarget("nearest", playerId, {
205
- candidates: () => enemies,
206
- distance: (from, to) => ctx.scene.entity.distance(from, to),
207
- });
208
- if (targetId === null)
209
- return;
210
- const target = ctx.scene.entity.get(targetId);
211
- if (target === null)
212
- return;
213
- const dx = target.position[0] - playerPos[0];
214
- const dz = target.position[2] - playerPos[2];
215
- const distance = Math.hypot(dx, dz);
216
- if (distance > weapon.range)
217
- return;
218
- run.kit.cast(weaponId);
219
- const level = run.weaponLevels[weaponId] ?? 1;
220
- const damage = leveled(weapon.damage, level) * damageMultiplier(run);
221
- const aim = aimToPoint(playerPos, target.position);
222
- const shotId = ctx.scene.entity.fireProjectile({ from: playerId, via: { amount: damage }, aim, effect: HIT_EFFECT });
223
- const travelSeconds = Math.min(1.1, Math.max(0.08, distance / weapon.speed));
224
- run.fxCounter += 1;
225
- run.bolts.push({
226
- id: run.fxCounter,
227
- weaponId,
228
- origin: playerPos,
229
- target: target.position,
230
- firedAt: ctx.time.now(),
231
- travelSeconds,
232
- });
233
- ctx.time.after(travelSeconds, () => {
234
- ctx.scene.entity.settleProjectile(shotId);
235
- });
236
- }
237
- function fireOrbit(ctx, run, weaponId, weapon, playerId, playerPos, enemies) {
238
- run.kit.cast(weaponId);
239
- const level = run.weaponLevels[weaponId] ?? 1;
240
- const damage = leveled(weapon.damage, level) * damageMultiplier(run);
241
- const blades = leveled(weapon.blades, level);
242
- const radius = leveled(weapon.radius, level);
243
- const angle = ctx.time.now() * weapon.angularSpeed;
244
- const alreadyHit = new Set();
245
- for (let blade = 0; blade < blades; blade += 1) {
246
- const theta = angle + (blade * Math.PI * 2) / blades;
247
- const bladePos = [
248
- playerPos[0] + Math.cos(theta) * radius,
249
- playerPos[1],
250
- playerPos[2] + Math.sin(theta) * radius,
251
- ];
252
- for (const instanceId of ctx.scene.entity.inRadius(bladePos, weapon.hitRadius, (id) => enemies.includes(id))) {
253
- if (alreadyHit.has(instanceId))
254
- continue;
255
- alreadyHit.add(instanceId);
256
- ctx.scene.entity.effect({ from: playerId, to: instanceId, effect: HIT_EFFECT, via: { amount: damage } });
257
- }
258
- }
259
- }
260
- function firePulse(ctx, run, weaponId, weapon, playerId, playerPos) {
261
- run.kit.cast(weaponId);
262
- const level = run.weaponLevels[weaponId] ?? 1;
263
- const damage = leveled(weapon.damage, level) * damageMultiplier(run);
264
- const radius = leveled(weapon.radius, level);
265
- ctx.scene.entity.effect({ from: playerId, effect: HIT_EFFECT, via: { amount: damage }, at: playerPos, radius, falloff: "linear" });
266
- run.fxCounter += 1;
267
- run.pulses.push({
268
- id: run.fxCounter,
269
- weaponId,
270
- at: playerPos,
271
- firedAt: ctx.time.now(),
272
- durationSeconds: weapon.durationSeconds,
273
- maxRadius: radius,
274
- });
275
- }
276
- function fireWeapons(ctx, run, dt) {
277
- run.kit.tick(dt);
278
- const playerId = ctx.player.userId;
279
- const player = ctx.scene.entity.get(playerId);
280
- if (player === null)
281
- return;
282
- const enemies = enemyIds(ctx);
283
- for (const [weaponId, weapon] of Object.entries(spec.weapons)) {
284
- const state = run.kit.state(weaponId);
285
- if (state === null || !state.ready)
286
- continue;
287
- if (weapon.kind === "projectile") {
288
- fireProjectile(ctx, run, weaponId, weapon, playerId, player.position, enemies);
289
- }
290
- else if (weapon.kind === "orbit") {
291
- fireOrbit(ctx, run, weaponId, weapon, playerId, player.position, enemies);
292
- }
293
- else if (weapon.kind === "pulse") {
294
- firePulse(ctx, run, weaponId, weapon, playerId, player.position);
295
- }
296
- else {
297
- run.kit.cast(weaponId);
298
- const level = run.weaponLevels[weaponId] ?? 1;
299
- weapon.fire(ctx, run.view, {
300
- weaponId,
301
- level,
302
- damage: leveled(weapon.damage, level) * damageMultiplier(run),
303
- playerId,
304
- playerPosition: player.position,
305
- enemyIds: enemies,
306
- dt,
307
- });
308
- }
309
- }
310
- }
311
- function maybeOpenDraft(ctx, run) {
312
- if (run.pendingOffers !== null || run.levelUpQueue <= 0 || run.phase !== "playing")
313
- return;
314
- run.levelUpQueue -= 1;
315
- run.pendingOffers = run.draft.present(spec.progression.draft.choices);
316
- ctx.time.pause();
317
- notify(run);
318
- }
319
- function onLevelUp(ctx, run, level) {
320
- ctx.game.events.emit("stat.levelUp", { userId: ctx.player.userId, stat: "level", level });
321
- run.levelUpQueue += 1;
322
- maybeOpenDraft(ctx, run);
323
- }
324
- function advancePickups(ctx, run, dt) {
325
- const playerId = ctx.player.userId;
326
- if (ctx.scene.entity.get(playerId) === null)
327
- return;
328
- const magnetRadius = run.fields["magnetRadius"] ?? 0;
329
- for (const record of ctx.scene.worldItem.list()) {
330
- if (record.baseType !== XP_GEM_BASE_TYPE)
331
- continue;
332
- const distance = ctx.scene.entity.distance(record.instanceId, playerId);
333
- if (distance === null)
334
- continue;
335
- if (distance <= spec.xpGems.collectRadius) {
336
- ctx.scene.worldItem.consume(record.instanceId);
337
- track.grantXp({
338
- get: (userId, statId) => ctx.scene.entity.stats.get(userId, statId),
339
- set: (userId, statId, patch) => ctx.scene.entity.stats.set(userId, statId, patch),
340
- }, playerId, record.count, (level) => onLevelUp(ctx, run, level));
341
- continue;
342
- }
343
- if (distance > magnetRadius)
344
- continue;
345
- const next = ctx.scene.entity.moveToward(record.instanceId, playerId, { speed: spec.xpGems.pullSpeed, dt });
346
- if (next !== null) {
347
- const grounded = [
348
- next[0],
349
- ctx.world.groundHeightAt(next[0], next[2]),
350
- next[2],
351
- ];
352
- ctx.scene.entity.setPose(record.instanceId, { position: grounded });
353
- }
354
- }
355
- }
356
- function pruneFx(ctx, run) {
357
- const now = ctx.time.now();
358
- run.bolts = run.bolts.filter((bolt) => now - bolt.firedAt <= bolt.travelSeconds + 0.15);
359
- run.pulses = run.pulses.filter((pulse) => now - pulse.firedAt <= pulse.durationSeconds + 0.05);
360
- }
361
- function applyUpgrade(ctx, run, offerId) {
362
- const upgrade = spec.progression.draft.upgrades.find((entry) => entry.id === offerId);
363
- if (upgrade === undefined)
364
- return;
365
- const stacks = run.draft.stack().count(offerId);
366
- const effect = upgrade.effect;
367
- switch (effect.kind) {
368
- case "weaponLevel": {
369
- run.weaponLevels[effect.weapon] = 1 + stacks;
370
- run.kit = buildKit(spec, run.weaponLevels);
371
- return;
372
- }
373
- case "statBonus": {
374
- const current = ctx.scene.entity.stats.get(ctx.player.userId, effect.stat);
375
- const max = (current?.max ?? 0) + effect.amount;
376
- const value = (current?.current ?? 0) + effect.amount;
377
- ctx.scene.entity.stats.set(ctx.player.userId, effect.stat, { max, current: value });
378
- return;
379
- }
380
- case "fieldAdd": {
381
- const base = spec.fields?.[effect.field] ?? 0;
382
- run.fields[effect.field] = base + effect.amount * stacks;
383
- return;
384
- }
385
- case "fieldMultiply": {
386
- const base = spec.fields?.[effect.field] ?? 1;
387
- run.fields[effect.field] = base * effect.factor ** stacks;
388
- return;
389
- }
390
- case "custom": {
391
- effect.apply(ctx, run.view, stacks);
392
- return;
393
- }
394
- }
395
- }
396
- function chooseUpgrade(ctx, offerId) {
397
- const run = getRun(ctx);
398
- if (run.pendingOffers === null)
399
- return;
400
- if (!run.draft.choose(offerId))
401
- return;
402
- applyUpgrade(ctx, run, offerId);
403
- run.pendingOffers = null;
404
- ctx.time.play();
405
- notify(run);
406
- maybeOpenDraft(ctx, run);
407
- }
408
- function registerEvents(ctx) {
409
- const run = getRun(ctx);
410
- syncPhase(ctx, run);
411
- if (spec.rules.killLeaderboardStat !== undefined) {
412
- ctx.game.leaderboard?.track({ stat: spec.rules.killLeaderboardStat, scope: "profile" });
413
- }
414
- run.disposeEvents?.();
415
- run.disposeEvents = ctx.game.events.on("entity.died", (event) => {
416
- if (event.instanceId === ctx.player.userId) {
417
- if (spec.rules.lose?.kind === "playerDeath" && run.phase === "playing") {
418
- run.phase = "lost";
419
- syncPhase(ctx, run);
420
- notify(run);
421
- }
422
- return;
423
- }
424
- if (event.reason.kind !== "player_kill")
425
- return;
426
- const def = spec.enemies[event.catalogId];
427
- if (def === undefined)
428
- return;
429
- run.kills += 1;
430
- if (spec.rules.killLeaderboardStat !== undefined) {
431
- ctx.game.leaderboard?.increment(ctx.player.userId, spec.rules.killLeaderboardStat, { scope: "profile" });
432
- }
433
- ctx.scene.worldItem.spawn({
434
- itemId: XP_GEM_BASE_TYPE,
435
- baseType: XP_GEM_BASE_TYPE,
436
- position: event.position,
437
- count: def.xp,
438
- rarity: gemRarity(spec, def.xp),
439
- });
440
- });
441
- }
442
- function checkOutcome(ctx, run) {
443
- const win = spec.rules.win;
444
- if (win !== undefined) {
445
- const won = win.kind === "survive" ? run.playingSeconds >= win.seconds : win.check(ctx, run.view);
446
- if (won) {
447
- run.phase = "won";
448
- syncPhase(ctx, run);
449
- notify(run);
450
- return;
451
- }
452
- }
453
- const lose = spec.rules.lose;
454
- if (lose?.kind === "custom" && lose.check(ctx, run.view)) {
455
- run.phase = "lost";
456
- syncPhase(ctx, run);
457
- notify(run);
458
- }
459
- }
460
- function begin(ctx) {
461
- const run = getRun(ctx);
462
- if (run.phase !== "start")
463
- return;
464
- run.phase = run.countdownRemaining > 0 ? "countdown" : "playing";
465
- syncPhase(ctx, run);
466
- notify(run);
467
- }
468
- function despawnCartridgeEntities(ctx) {
469
- for (const entity of ctx.scene.entity.list()) {
470
- if (spec.enemies[entity.name] !== undefined)
471
- ctx.scene.entity.despawn(entity.id);
472
- }
473
- for (const record of ctx.scene.worldItem.list()) {
474
- if (record.baseType === XP_GEM_BASE_TYPE)
475
- ctx.scene.entity.despawn(record.instanceId);
476
- }
477
- }
478
- function reset(ctx) {
479
- const run = getRun(ctx);
480
- Object.assign(run, seededState());
481
- despawnCartridgeEntities(ctx);
482
- if (ctx.scene.entity.get(ctx.player.userId) === null) {
483
- ctx.scene.entity.spawn(spec.player.kind, {
484
- id: ctx.player.userId,
485
- position: spec.player.spawnAt ?? [0, 0, 0],
486
- });
487
- }
488
- ctx.scene.entity.stats.set(ctx.player.userId, "health", { max: spec.player.health, current: spec.player.health });
489
- ctx.scene.entity.stats.set(ctx.player.userId, "xp", { max: track.xpForLevel(1), current: 0 });
490
- ctx.scene.entity.stats.set(ctx.player.userId, "level", { current: 1 });
491
- ctx.scene.entity.setPose(ctx.player.userId, { position: spec.player.spawnAt ?? [0, 0, 0] });
492
- ctx.time.play();
493
- syncPhase(ctx, run);
494
- notify(run);
495
- }
496
- function tick(ctx, dt) {
497
- const run = getRun(ctx);
498
- if (dt <= 0 || run.pendingOffers !== null)
499
- return;
500
- if (run.phase === "countdown") {
501
- run.countdownRemaining -= dt;
502
- if (run.countdownRemaining <= 0) {
503
- run.countdownRemaining = 0;
504
- run.phase = "playing";
505
- syncPhase(ctx, run);
506
- }
507
- notify(run);
508
- return;
509
- }
510
- if (run.phase !== "playing")
511
- return;
512
- run.playingSeconds += dt;
513
- advanceSpawning(ctx, run, dt);
514
- advanceEnemies(ctx, run, dt);
515
- fireWeapons(ctx, run, dt);
516
- advancePickups(ctx, run, dt);
517
- pruneFx(ctx, run);
518
- for (const system of spec.systems ?? [])
519
- system(ctx, run.view, dt);
520
- checkOutcome(ctx, run);
521
- }
522
- const loop = {
523
- onInit(ctx) {
524
- registerEvents(ctx);
525
- if (spec.flow?.restart === true && !ctx.game.commands.has("restart")) {
526
- ctx.game.commands.define("restart", {
527
- apply(state) {
528
- reset(ctx);
529
- return state;
530
- },
531
- });
532
- }
533
- },
534
- onNewPlayer(ctx) {
535
- ctx.scene.entity.spawn(spec.player.kind, {
536
- id: ctx.player.userId,
537
- position: spec.player.spawnAt ?? [0, 0, 0],
538
- });
539
- ctx.scene.entity.stats.set(ctx.player.userId, "xp", { max: track.xpForLevel(1), current: 0 });
540
- ctx.scene.entity.stats.set(ctx.player.userId, "level", { current: 1 });
541
- },
542
- onTick(ctx, dt) {
543
- tick(ctx, dt);
544
- },
545
- };
546
- return {
547
- content: { entityById: (id) => entities[id] ?? null },
548
- loop,
549
- run: (ctx) => getRun(ctx).view,
550
- weaponKit: (ctx) => getRun(ctx).kit,
551
- chooseUpgrade,
552
- begin,
553
- reset,
554
- spec,
555
- };
556
- }