Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
19 changes: 0 additions & 19 deletions .claude/launch.json

This file was deleted.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,6 @@ dist

# Local VFX capture scratch (dev tooling)
.captures/

# Agent debug tooling and dev logs (not part of the project)
.scratch/
139 changes: 104 additions & 35 deletions README.md

Large diffs are not rendered by default.

Binary file removed icecast.jpg
Binary file not shown.
7 changes: 7 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ <h1 class="loader__title">Elemental Sandbox</h1>
<!-- Heads-up display (populated by src/ui/HUD.js) -->
<div id="hud" class="hud" aria-live="polite"></div>

<!--
Spellbook overlay (populated by src/ui/Spellbook.js). A sibling of #hud
rather than a child: it sits *below* the HUD in the stack so the loadout
bar stays lit and droppable over the top of it while the book is open.
-->
<div id="spellbook" class="spellbook"></div>

<script type="module" src="./src/main.js"></script>
</body>
</html>
24 changes: 1 addition & 23 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"check": "node scripts/check.mjs",
"check:build": "npm run check && npm run build"
},
"dependencies": {
"lil-gui": "^0.21.0",
Expand Down
56 changes: 56 additions & 0 deletions src/abilities/Ability.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ export class Ability {
/** Transient additive light punch (impacts). Decays on its own. */
this.lightBoost = 0;

/**
* Handles this cast has borrowed from a **global** pool — a scene hook
* (`vfx/SceneHooks.js`), a time region (`vfx/TimeControl.js`), anything
* else whose `acquire()` takes a slice of the world away from everybody
* else. See `borrow()`.
*
* Allocated once, per instance, at construction; `destroy()` empties it in
* place. An ability is pooled, so this array is created a handful of times
* for the life of the app and never during a cast — I3 holds.
*/
this.borrowed = [];

this.createShaders();
this.createParticles();
}
Expand Down Expand Up @@ -263,11 +275,55 @@ export class Ability {
this.lightBoost = Math.max(0, this.lightBoost - this.lightBoost * 4.5 * dt - 0.5 * dt);
}

/**
* Register a borrowed global handle so `destroy()` gives it back.
*
* ```js
* this.region = this.borrow(timeField.acquire()); // may be null
* this.grade = this.borrow(sceneHooks.acquire(Hook.GRADE, this));
* ```
*
* This exists because a cast can end in four different ways and only one of
* them is the ability's idea. It finishes normally; the player presses **C**
* and `AbilityManager#clear()` destroys it mid-flight; a fifth cast pushes it
* off the front of the concurrency cap; the app tears down. `onDestroy()` is
* called on all four, so an ability that releases there is already correct —
* and every one of the fifty is. The net is here for the same reason
* `ctx.lights.release(this.light)` is on the line below it rather than in
* fifty `onDestroy()` bodies: a light that leaks costs the next cast its
* light, but a **scene hook** that leaks holds the sun, the grade or the
* floor's material wrong for the rest of the session, and a leaked **time
* region** stops a sphere of the world permanently. Those are not failures
* anybody would trace back to the ability that caused them.
*
* `SceneHooks` does carry an eight-frame lease sweep as a second net, but it
* recovers with a console warning several frames late; this recovers exactly,
* on the frame, silently. `TimeField` has no sweep at all — it cannot have
* one, because nothing ticks it — so for time regions this *is* the net.
*
* Nothing is imported to make it work: every such handle knows its own pool
* (`token.hooks`, `region._field`) and every `release()` in the project is
* idempotent, so releasing here and again in `onDestroy()` is harmless and
* the order does not matter.
*
* @template T
* @param {T} handle anything with a `release()`; `null` passes through
* @returns {T} the same handle, so this wraps the acquisition inline
*/
borrow(handle) {
if (handle) this.borrowed.push(handle);
return handle;
}

/** Return to the pool. Must leave the instance reusable. */
destroy() {
this.onDestroy();
this.ctx.lights.release(this.light);
this.light = null;
// Backwards: a handle released inside onDestroy() is already inert, and
// popping from the end keeps this allocation-free.
for (let i = this.borrowed.length - 1; i >= 0; i--) this.borrowed[i]?.release?.();
this.borrowed.length = 0;
this.group.visible = false;
this.phase = AbilityPhase.IDLE;
}
Expand Down
176 changes: 137 additions & 39 deletions src/abilities/AbilityManager.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,43 @@
import { IceAbility } from './IceAbility.js';
import { ThunderAbility } from './ThunderAbility.js';
import { MeteorAbility } from './MeteorAbility.js';
import { BeamAbility } from './BeamAbility.js';
import { SnareAbility } from './SnareAbility.js';
import { GlacierAbility } from './GlacierAbility.js';
import { ELEMENTS } from '../config/settings.js';
import { ABILITY_IDS, getAbility } from './registry.js';
import { ObjectPool } from '../utils/ObjectPool.js';

/** Registry: adding an ability means adding one line here. */
const ABILITY_TYPES = {
ice: IceAbility,
thunder: ThunderAbility,
meteor: MeteorAbility,
beam: BeamAbility,
snare: SnareAbility,
glacier: GlacierAbility
};

const MAX_CONCURRENT = 4;

/**
* Spawns, updates and recycles abilities.
*
* Instances are pooled per type: casting fifty times constructs at most a
* handful of objects per ability, and every one of them keeps its meshes and
* materials for the lifetime of the app. Nothing is built during a cast.
* Instances are pooled per id: casting fifty times constructs at most a handful
* of objects per ability, and every one of them keeps its meshes and materials
* for the lifetime of the app. Nothing is built during a cast.
*
* `MAX_CONCURRENT` is shared across ids, so mixing abilities retires the oldest
* cast whichever one it was.
*
* ## Laziness
*
* The manager used to import all six ability classes at the top of this file
* and build a pool for each in its constructor. At fifty that is fifty modules
* parsed and fifty sets of meshes, materials and particle systems constructed
* before the loading bar has finished — for the one ability the player is about
* to press Q on.
*
* `MAX_CONCURRENT` is shared across types, so mixing abilities retires the
* oldest cast whichever element it was.
* So a pool is now built the first time an id is **selected or cast**, from the
* registry descriptor's `load()`. The awkward part is that an import is a
* promise and a cast is a click: the player is not going to wait a microtask
* for `Frost Lance` to arrive. Three things resolve that, in order of how much
* they matter:
*
* 1. `App` calls `warm(id)` the moment an ability is *selected*, which is
* always at least one frame — usually several seconds — before the click.
* By the time the arrow has swept out the class is in memory and the pool
* is primed with a live instance.
* 2. The constructor warms whatever is in slot one, so the very first cast of
* a session is ready too.
* 3. If a cast still arrives cold, `cast()` kicks off the import and returns
* `null` — nothing is drawn this frame, and the next click works. It never
* awaits, because an `async` cast would put an ability on screen a frame
* after the animation that threw it, and a spell that lags its own gesture
* reads as broken in a way a dropped first cast does not.
*/
export class AbilityManager {
/**
Expand All @@ -37,25 +47,101 @@ export class AbilityManager {
constructor(context) {
this.ctx = context;
this.active = [];
this.selected = ELEMENTS[0];
this.selected = ABILITY_IDS[0];

/** id → ObjectPool, created on first warm. */
this.pools = new Map();
for (const [element, Type] of Object.entries(ABILITY_TYPES)) {
this.pools.set(
element,
new ObjectPool(() => {
const ability = new Type(this.ctx);
this.ctx.scene.add(ability.group);
ability.group.visible = false;
return ability;
})
);
}
/** id → the loaded class, once its import has settled. */
this._classes = new Map();
/** id → the in-flight import, so a held key never fires two of them. */
this._loading = new Map();

// Slot one is armed before the player has touched anything; have it ready.
this.warm(this.selected);
}

/* ------------------------------------------------------------------ */
/* Loading */
/* ------------------------------------------------------------------ */

/** Whether this id can be cast on the current frame. */
isReady(id) {
return this.pools.has(id);
}

/**
* Load an ability's class and prime its pool, off the frame loop.
*
* Idempotent and cheap to call every time a slot is selected: an id that is
* already loaded resolves immediately, and an id that is mid-import returns
* the same promise rather than starting a second one.
*
* Priming means constructing one instance now, which is where the meshes,
* materials and particle systems get built — the expensive part, moved off
* the click and onto the selection. The instance goes into the scene
* invisible, exactly as the eager constructor used to leave it.
*
* @param {string} id
* @returns {Promise<boolean>} true once the id is castable
*/
warm(id) {
if (this.pools.has(id)) return Promise.resolve(true);

const existing = this._loading.get(id);
if (existing) return existing;

const descriptor = getAbility(id);
if (!descriptor) return Promise.resolve(false);

const pending = descriptor
.load()
.then((Type) => {
this._classes.set(id, Type);
// Two selections can land in the same tick; the first one to arrive
// wins and the second finds the pool already built.
if (!this.pools.has(id)) {
const pool = new ObjectPool(() => {
const ability = new Type(this.ctx);
this.ctx.scene.add(ability.group);
ability.group.visible = false;
return ability;
});
// Prime with one, so the first cast of this id allocates nothing.
pool.release(pool.acquire());
this.pools.set(id, pool);
}
return true;
})
.catch((error) => {
// A failed import must not poison the slot forever — clearing the
// in-flight entry lets the next selection try again.
console.error(`AbilityManager: failed to load "${id}"`, error);
return false;
})
.finally(() => {
this._loading.delete(id);
});

this._loading.set(id, pending);
return pending;
}

select(element) {
if (!ABILITY_TYPES[element]) return;
this.selected = element;
/* ------------------------------------------------------------------ */
/* Casting */
/* ------------------------------------------------------------------ */

/**
* Put an ability in the slot.
*
* Deliberately *only* state: warming is `App`'s call, made from
* `selectAbility()` at the same moment, and keeping the two separate means
* the spellbook can warm an ability it is merely hovering without selecting
* it. A caller that selects and forgets to warm loses one cast and no more —
* `cast()` self-heals.
*/
select(id) {
if (!getAbility(id)) return;
this.selected = id;
}

/**
Expand All @@ -64,13 +150,23 @@ export class AbilityManager {
* A far cast takes the same three arguments and simply works from the far end
* of that line — which is why adding zone targeting needed nothing here.
*
* Returns `null` if the id is unknown, or if its class has not finished
* loading; in the second case the import is kicked off and the next cast
* works. See the class header for why this does not await.
*
* @param {THREE.Vector3} origin on the floor
* @param {THREE.Vector3} direction unit, flat
* @param {number} distance metres
* @returns {import('./Ability.js').Ability|null}
*/
cast(origin, direction, distance, element = this.selected) {
if (!ABILITY_TYPES[element]) return null;
if (!getAbility(element)) return null;

const pool = this.pools.get(element);
if (!pool) {
this.warm(element);
return null;
}

// Retire the oldest cast rather than letting the scene grow without bound.
if (this.active.length >= MAX_CONCURRENT) {
Expand All @@ -79,7 +175,7 @@ export class AbilityManager {
this.pools.get(oldest.element).release(oldest);
}

const ability = this.pools.get(element).acquire();
const ability = pool.acquire();
ability.spawn(origin, direction, distance);
this.active.push(ability);
return ability;
Expand Down Expand Up @@ -118,5 +214,7 @@ export class AbilityManager {
this.clear();
for (const pool of this.pools.values()) pool.dispose((ability) => ability.dispose());
this.pools.clear();
this._classes.clear();
this._loading.clear();
}
}
2 changes: 1 addition & 1 deletion src/abilities/IceAbility.js
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ export class IceAbility extends Ability {
_emit.spin = 7;
_emit.tint = null;
_emit.time = time;
this.shards.emit(Math.round(3 * g.particleCount), _emit);
this.shards.emit(Math.round(c.breachShards * g.particleCount), _emit);

// Only some spikes puff: a few hundred smoking at once buries the field in
// haze and hides the silhouette that is the whole point.
Expand Down
Loading