explodorama / commit
The rainbow gem offers every charge too
CHANGELOG.md | 4 ++-- PLAN.md | 2 +- README.md | 2 +- decisions.md | 12 ++++++++++++ src/main.ts | 2 +- src/sim/turn.ts | 13 +++++++------ src/sim/types.ts | 23 +++++++++++++++++------ src/ui/info.ts | 4 ++-- src/ui/offer.ts | 18 ++++++++++-------- test/options.test.ts | 30 +++++++++++++++++++++++++----- test/powerups.test.ts | 4 ++-- 11 files changed, 80 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f60545a..a64a141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,8 +102,8 @@ saved before the change is dropped and the next visit starts a new run. - Developer mode now only adds an extra gem next to the player on every level (the level's own gem stays hidden); it no longer gives every ability, extra stats or unlimited charges. - Developer mode's extra gem is a **rainbow gem** that turns and glitters, so it can't be taken - for the level's own, and it offers **every power-up you don't have yet** instead of 5 - (`SIM_VERSION` 23). + for the level's own, and it offers **every power-up you don't have yet, and every charge** + (Freeze, Decoy and the rest, to test them at once) instead of 5 power-ups (`SIM_VERSION` 23). - The board is drawn with **WebGL** again (it was Canvas 2D). ### Removed diff --git a/PLAN.md b/PLAN.md index 8983246..f6ab77b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -694,7 +694,7 @@ parameters: `?dev`, `?immortal`. |---|---| | ~~**Board: Normal**~~ | *Removed 2026-10-23.* 31 × 13, density 35%, 50 levels, enemies 3 + 1 per 3 levels (max 10), par 50 + 10/enemy. | | ~~**Board: Small**~~ | *Now the only board (no longer an option).* 9 × 11 (fits a phone whole; the camera scales to fit both ways, no scrolling), density 25%, **20 levels**, enemies 2 + 1 per 4 levels (max 5), par 25 + 8/enemy. Same enemy-type schedule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. | -| **Developer** | *Since 2026-10-23 (user request), only this:* an **extra** gem (`PowerUp.extra`; since 2026-10-24 a turning, glittering rainbow gem offering every power-up not held yet) lies right of the start, every level and after every death, to try the choice at once; the level keeps its own gem, hidden as usual (at first it was that gem, moved). Achievements count, with a red border (the victory banner is framed in red). (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) | +| **Developer** | *Since 2026-10-23 (user request), only this:* an **extra** gem (`PowerUp.extra`; since 2026-10-24 a turning, glittering rainbow gem offering every power-up not held yet and every charge) lies right of the start, every level and after every death, to try the choice at once; the level keeps its own gem, hidden as usual (at first it was that gem, moved). Achievements count, with a red border (the victory banner is framed in red). (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) | | **Immortal** (called God until 2026-10-23) | The player can't be hurt at all, so the run can be played to the end — but **an Immortal run earns no achievements** (the victory certificate, the Workshop's rewards, when they exist). | | ~~**Crumbling crates**~~ | *Always on since 2026-10-23 (no longer an option).* When the last enemy dies, every crate gets a countdown of 1–3 turns (seeded, `hash(seed, "crumble", level)`), shown by shaking and fading; at 0 it is gone, revealing whatever was under it. It is not flame: no punishment, nothing burned. | diff --git a/README.md b/README.md index 241237f..21a9c01 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ modes (there is one board size, and crates always crumble once the enemies are g | Mode | What it does | |---|---| -| **Developer** (checkbox) | **An extra, rainbow gem lies right next to you** at the start of every level (and after a death), turning and glittering so it can't be mistaken for the level's own. It offers **every power-up you don't have yet** (not the usual 5), to try any of them at once. The level's own gem is still hidden under a crate as usual. Achievements still count, but with a red border (the victory banner is framed in red), like the red quarters some bars kept for their own jukebox plays. | +| **Developer** (checkbox) | **An extra, rainbow gem lies right next to you** at the start of every level (and after a death), turning and glittering so it can't be mistaken for the level's own. It offers **every power-up you don't have yet, and every charge** (not the usual 5 power-ups), to try any of them at once. The level's own gem is still hidden under a crate as usual. Achievements still count, but with a red border (the victory banner is framed in red), like the red quarters some bars kept for their own jukebox plays. | | **Immortal** (checkbox) | Nothing can kill you, so you can play through to the end — but an Immortal run earns no achievements. (It was called God.) | **Seed:** the New run dialog also has an optional seed field. Empty means a new, random diff --git a/decisions.md b/decisions.md index 86cc9f1..c928103 100644 --- a/decisions.md +++ b/decisions.md @@ -1119,3 +1119,15 @@ rainbow/glittering/strange gem", clearly a very special one, offering every poss nothing drawn (so no random stream). The dialog scrolls (15 at most). `SIM_VERSION` 23, since a Developer run saved before would replay differently. - Screenshots: `dev` shows the rainbow gem; new `dev-offer` shows its choice. + +### 2026-10-24 — The rainbow gem offers every charge too + +User request: so Freeze and the other charges can be tried without waiting for an enemy to drop +them. The rainbow gem's offer lists every power-up that would do anything, then all six charges +(`OfferKind` = a power-up or a charge; `isCharge`). A chosen charge works exactly as a picked-up +one (`applyCharge`): bomb boosts add BOMB_BOOST, Shield goes up, Freeze freezes every enemy, +Decoy is left on the gem's tile. Choosing takes no turn and comes after the gem's turn, so +a Freeze chosen this way skips the enemies' next 3 moves (one picked up skips its own turn's). A level's own gem never offers a charge. +`SIM_VERSION` stays 23: a run recorded before still replays the same (its choices are still on +offer), and 23 itself is from earlier today. + diff --git a/src/main.ts b/src/main.ts index 281d9aa..298ae7b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -390,7 +390,7 @@ function newRunDialog(): void { body: warning + `<fieldset class="choices"><legend>Modes</legend>` + - `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> An extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet (the level’s own is still hidden). Achievements still count, marked with a red border.</span></label>` + + `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> An extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet and every charge (the level’s own is still hidden). Achievements still count, marked with a red border.</span></label>` + `<label><input type="checkbox" name="immortal"${checked(o.immortal)}><span><b>Immortal</b> Nothing can kill you. You can play through to the end, but an Immortal run earns no achievements.</span></label>` + `</fieldset><fieldset class="choices"><legend>Seed</legend>` + `<label class="seed"><input type="text" name="seed" inputmode="numeric" autocomplete="off" maxlength="40" placeholder="random"><span>Leave it empty for a new dungeon, or type the seed of one to play it (shown in the menu and at the end of a run).</span></label>` + diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 9eccb60..60a8a5f 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -4,7 +4,7 @@ import { enemyKinds, LAST_LEVEL } from './levelgen' import { detonate, tickBombs } from './explosions' import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state' import { stream } from './rng' -import { DIRS, DIR_LIST, type Action, type Death, type GameEvent, type GameState, type Powers, type PowerUpKind, type Vec } from './types' +import { CHARGE_IDS, DIRS, DIR_LIST, isCharge, type Action, type Death, type GameEvent, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from './types' export interface StepResult { state: GameState @@ -29,9 +29,10 @@ export function step(prev: GameState, action: Action): StepResult { // The gem's choice: no turn passes, nothing else moves. if (action.id === 'choose') { if (!s.offer?.includes(action.kind)) throw new Error(`${action.kind} is not on offer`) - applyPowerUp(s, action.kind) s.offer = null events.push({ type: 'powerUpChosen', kind: action.kind }) + if (isCharge(action.kind)) applyCharge(s, action.kind, events) + else applyPowerUp(s, action.kind) return { state: s, events } } // An offer must be answered first, even with the level cleared; a death drops it. @@ -293,15 +294,15 @@ const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = { /** * A gem's offer: OFFER_SIZE different power-ups (fewer if fewer would do anything), drawn by * POWER_UP_WEIGHTS and listed in its order. From the seed and level, so a retry after a death - * offers the same. Developer mode's extra gem offers every one that would do anything, nothing - * drawn (user request, 2026-10-24). + * offers the same. Developer mode's extra gem offers every one that would do anything, then + * every charge, nothing drawn (user request, 2026-10-24). */ -export function drawOffer(s: GameState, extra = false): PowerUpKind[] { +export function drawOffer(s: GameState, extra = false): OfferKind[] { const pool = POWER_UP_WEIGHTS.filter(([kind]) => { const held = HELD_AS[kind] return !held || !s.powers[held] }) - if (extra) return pool.map(([kind]) => kind) + if (extra) return [...pool.map(([kind]) => kind), ...CHARGE_IDS] const rng = stream(s.seed, 'offer', s.level) const picked = new Set<PowerUpKind>() while (picked.size < OFFER_SIZE && pool.length) { diff --git a/src/sim/types.ts b/src/sim/types.ts index 0689480..30d4ed4 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -160,6 +160,16 @@ export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'deco export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy'] +/** + * What a gem can offer: a power-up, or a charge (only Developer mode's rainbow gem, to test them + * at once: user request, 2026-10-24). A chosen charge works as if picked up. + */ +export type OfferKind = PowerUpKind | ChargeId + +export function isCharge(kind: OfferKind): kind is ChargeId { + return (CHARGE_IDS as readonly OfferKind[]).includes(kind) +} + /** What picked-up charges still have to give. Kept from level to level and through deaths. */ export interface Boosts { /** How many of the player's next bombs pierce, reach the walls, nuke. Each bomb placed uses one of each. */ @@ -220,10 +230,11 @@ export interface GameState { */ gems: PowerUp[] /** - * The power-ups the gem just taken offers. While it is set, the only action is `choose`; a - * death (or game over) in the same turn drops it, and the retry has the gem back. + * The power-ups (and, from the rainbow gem, charges) the gem just taken offers. While it is + * set, the only action is `choose`; a death (or game over) in the same turn drops it, and the + * retry has the gem back. */ - offer: PowerUpKind[] | null + offer: OfferKind[] | null /** * Tile indices still on fire from last turn's explosions. Flame lingers one extra turn: it * kills creatures that are in it or walk into it this turn, and the player can't step or put @@ -244,7 +255,7 @@ export interface GameState { export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal' /** `choose`: one of the gem's offer. It takes no turn. */ -export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } | { id: 'choose'; kind: PowerUpKind } +export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } | { id: 'choose'; kind: OfferKind } /** * How the player died, for the death and game-over banners (user request, 2026-10-24): walked @@ -285,8 +296,8 @@ export type GameEvent = | { type: 'decoyGone'; pos: Vec; burned: boolean } | { type: 'powerUpRevealed'; powerUp: PowerUp } | { type: 'powerUpBurned'; powerUp: PowerUp } - | { type: 'powerUpTaken'; powerUp: PowerUp; offer: PowerUpKind[] } - | { type: 'powerUpChosen'; kind: PowerUpKind } + | { type: 'powerUpTaken'; powerUp: PowerUp; offer: OfferKind[] } + | { type: 'powerUpChosen'; kind: OfferKind } | { type: 'detonated'; pos: Vec } | { type: 'runWon'; turns: number; deaths: number } | { type: 'cratesCrumbling'; count: number } diff --git a/src/ui/info.ts b/src/ui/info.ts index 42b1681..3328af0 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -102,7 +102,7 @@ export function deathLine(level: number, d: Death | null): string { /** One line per mode the run has on (none in a plain run). */ export function modeLines(s: GameState): string[] { const lines: string[] = [] - if (s.options.dev) lines.push('Developer mode: an extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet. The level’s own gem is still hidden under a crate. Achievements still count, marked with a red border.') + if (s.options.dev) lines.push('Developer mode: an extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet and every charge. The level’s own gem is still hidden under a crate. Achievements still count, marked with a red border.') if (s.options.immortal) lines.push('Immortal: nothing can kill you. You can play through to the end, but the run earns no achievements.') return lines } @@ -251,7 +251,7 @@ export function tileInfo(s: GameState, p: Vec): Info | null { title: gem.extra ? 'Rainbow gem (Developer mode)' : 'Gem', body: [ gem.extra - ? 'Walk onto it to choose any power-up you don’t have yet: every one is offered.' + ? 'Walk onto it to choose any power-up you don’t have yet, or any charge: every one is offered.' : `Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`, PUNISH, ], diff --git a/src/ui/offer.ts b/src/ui/offer.ts index fc4ae9e..51cb456 100644 --- a/src/ui/offer.ts +++ b/src/ui/offer.ts @@ -1,15 +1,16 @@ -import type { PowerUpKind } from '../sim/types' -import { POWER_UP_ICONS, spriteUrl } from '../render/sprites' +import { isCharge, type OfferKind } from '../sim/types' +import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' import { closeDialog, openDialog } from './dialog' -import { POWER_UP_TEXT } from './info' +import { CHARGE_TEXT, POWER_UP_TEXT } from './info' /** * The gem's choice (user request, 2026-10-23): its offer of power-ups, each with what it does, * laid out like the almanac. One must be picked; the dialog can't be closed any other way. + * Developer mode's rainbow gem offers the charges too (2026-10-24). */ -let chosen: ((kind: PowerUpKind) => void) | null = null +let chosen: ((kind: OfferKind) => void) | null = null -export function openOffer(offer: PowerUpKind[], choose: (kind: PowerUpKind) => void): void { +export function openOffer(offer: OfferKind[], choose: (kind: OfferKind) => void): void { chosen = choose openDialog({ title: 'Choose a power-up', @@ -17,8 +18,9 @@ export function openOffer(offer: PowerUpKind[], choose: (kind: PowerUpKind) => v .map( (k) => `<button type="button" class="almanac-entry offer-choice" data-offer="${k}">` + - `<img src="${spriteUrl(POWER_UP_ICONS[k])}" alt="" draggable="false">` + - `<div><strong>${POWER_UP_TEXT[k].name}</strong><p>${POWER_UP_TEXT[k].text}</p></div></button>`, + `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false">` + + `<div><strong>${(isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]).name}</strong>` + + `<p>${(isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k]).text}</p></div></button>`, ) .join(''), buttons: [], @@ -32,5 +34,5 @@ dialogBody.addEventListener('click', (e) => { if (!button || !chosen) return const choose = chosen chosen = null - closeDialog(() => choose(button.dataset.offer as PowerUpKind)) + closeDialog(() => choose(button.dataset.offer as OfferKind)) }) diff --git a/test/options.test.ts b/test/options.test.ts index 19e8081..0f9643d 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -3,7 +3,8 @@ import { enemyCount, generateLevel, LAST_LEVEL, START } from '../src/sim/levelge import { newRecord, replay } from '../src/sim/record' import { newRun } from '../src/sim/state' import { drawOffer, POWER_UP_WEIGHTS, step } from '../src/sim/turn' -import { DEFAULT_OPTIONS, type Vec } from '../src/sim/types' +import { CHARGE_IDS, DEFAULT_OPTIONS, isCharge, type Vec } from '../src/sim/types' +import { BOMB_BOOST, FREEZE_TURNS } from '../src/sim/actions' import { board, v } from './helpers' describe('the board (one size since 2026-10-23)', () => { @@ -99,13 +100,32 @@ describe('developer mode', () => { } }) - it('the extra gem is the rainbow one, and offers every power-up (user request, 2026-10-24)', () => { + it('the extra gem is the rainbow one, and offers every power-up and charge (user request, 2026-10-24)', () => { const s = newRun(1, 1, 0, dev) expect(s.gems.find((g) => g.extra)!.color).toBe('rainbow') expect(s.gems.find((g) => !g.extra)!.color).not.toBe('rainbow') - expect(drawOffer(s, true)).toEqual(POWER_UP_WEIGHTS.map(([kind]) => kind)) + const everything = [...POWER_UP_WEIGHTS.map(([kind]) => kind), ...CHARGE_IDS] + expect(drawOffer(s, true)).toEqual(everything) const { state } = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } }) - expect(state.offer).toEqual(POWER_UP_WEIGHTS.map(([kind]) => kind)) + expect(state.offer).toEqual(everything) + }) + + it('a charge chosen from the extra gem works as if picked up', () => { + const s = newRun(1, 1, 0, dev) + const taken = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } }).state + expect(step(taken, { id: 'choose', kind: 'freeze' }).state.frozen).toBe(FREEZE_TURNS) + expect(step(taken, { id: 'choose', kind: 'shield' }).state.shield).toBe(true) + expect(step(taken, { id: 'choose', kind: 'nuke' }).state.boosts.nuke).toBe(BOMB_BOOST) + const decoy = step(taken, { id: 'choose', kind: 'decoy' }) + expect(decoy.state.decoy?.pos).toEqual(taken.player) + expect(decoy.events).toContainEqual({ type: 'decoyPlaced', pos: taken.player }) + expect(decoy.state.offer).toBeNull() + }) + + it('a level\'s own gem never offers a charge', () => { + for (let seed = 1; seed <= 30; seed++) { + expect(drawOffer(newRun(seed, 1, 0, dev)).some(isCharge)).toBe(false) + } }) it('the extra gem leaves out what the player already has', () => { @@ -115,7 +135,7 @@ describe('developer mode', () => { const offer = drawOffer(s, true) expect(offer).not.toContain('run') expect(offer).not.toContain('flamePass') - expect(offer).toHaveLength(POWER_UP_WEIGHTS.length - 2) + expect(offer).toHaveLength(POWER_UP_WEIGHTS.length - 2 + CHARGE_IDS.length) // Stats, a Star, a heart and coins can always be had again. expect(offer).toEqual(expect.arrayContaining(['bombPlus', 'firePlus', 'fusePlus', 'star', 'heart', 'coinBag'])) }) diff --git a/test/powerups.test.ts b/test/powerups.test.ts index 9b99acd..890e4f0 100644 --- a/test/powerups.test.ts +++ b/test/powerups.test.ts @@ -3,7 +3,7 @@ import { ACTIONS, isAvailable } from '../src/sim/actions' import { generateLevel, LAST_LEVEL } from '../src/sim/levelgen' import { newRun } from '../src/sim/state' import { COIN_BAG, drawOffer, EXIT_SPAWN, OFFER_SIZE, POWER_UP_WEIGHTS, STAR_TURNS, step } from '../src/sim/turn' -import type { GameState, PowerUpKind } from '../src/sim/types' +import type { GameState, OfferKind, PowerUpKind } from '../src/sim/types' import { board, v } from './helpers' describe('levelgen: the hidden gem', () => { @@ -37,7 +37,7 @@ function offering(...offer: PowerUpKind[]): GameState { return s } -const ORDER = POWER_UP_WEIGHTS.map(([kind]) => kind) +const ORDER: OfferKind[] = POWER_UP_WEIGHTS.map(([kind]) => kind) describe('the offer', () => { it(`is ${OFFER_SIZE} different power-ups, in the almanac's order, the same for the same seed and level`, () => {