From e0a50aac18a2b1427ae3efbb18dcb019ecb16ce9 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Wed, 9 Sep 2026 19:52:27 +0300 Subject: [PATCH] prnd with world seed --- src/App.css | 22 +++++++++++++++ src/app/App.ts | 12 ++++++-- src/app/SettingsScreen.ts | 33 ++++++++++++++++++++-- src/game/GameStore.ts | 46 +++++++++++++++++++++++++------ src/game/entities/Rabbit.ts | 12 +++++--- src/game/entities/ResourceDrop.ts | 7 +++-- src/game/persistence.ts | 7 +++++ src/game/random.ts | 39 ++++++++++++++++++++++++++ src/game/spawning.ts | 7 +++-- 9 files changed, 161 insertions(+), 24 deletions(-) create mode 100644 src/game/random.ts diff --git a/src/App.css b/src/App.css index e2cf3c1..23408c6 100644 --- a/src/App.css +++ b/src/App.css @@ -111,6 +111,28 @@ cursor: not-allowed; } +.settings-seed-label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--text); +} + +.settings-seed-input { + padding: 14px 16px; + background: rgba(0, 0, 0, 0.25); + border: 4px solid var(--panel-border); + color: var(--text); + font-family: inherit; + font-size: 14px; +} + +.settings-seed-input:focus { + outline: none; + border-color: var(--accent); +} + .main-row { display: flex; flex-direction: row; diff --git a/src/app/App.ts b/src/app/App.ts index 1a021cb..fba16e9 100644 --- a/src/app/App.ts +++ b/src/app/App.ts @@ -53,7 +53,11 @@ export class App { } private showSettings(): void { - const settings = createSettingsScreen(() => this.showMenu()); + const settings = createSettingsScreen( + this.store.seed, + () => this.showMenu(), + (seed) => this.handleNewGame(seed), + ); this.root.replaceChildren(settings); } @@ -69,7 +73,9 @@ export class App { this.gameScreen.mount(); } - private handleNewGame(): void { + /** Starts a fresh game — a freshly random seed by default, or an explicit one when called from + * Settings' seed field (which also acts as "restart the game with this seed"). */ + private handleNewGame(seed?: number): void { // A resumable save existing means there's a live (not-dead) game being abandoned by starting // fresh instead of continuing it — record that as the last level's failure. A death already // records its own failure at the moment it happens (see GameStore.landRabbitStrike) and clears @@ -77,6 +83,8 @@ export class App { if (hasSave()) this.analytics.death('restarted'); clearSave(); + if (seed !== undefined) + this.store.setNextSeed(seed); this.showGame(); } diff --git a/src/app/SettingsScreen.ts b/src/app/SettingsScreen.ts index b53ad90..12e1e55 100644 --- a/src/app/SettingsScreen.ts +++ b/src/app/SettingsScreen.ts @@ -23,8 +23,14 @@ function createToggle(label: string, initial: boolean, onChange: (value: boolean } /** Builds the settings screen — rendering-diagnostics toggles that take effect the next time a - * game is (re)started (GameCanvas reads them once, on mount), reachable from the main menu. */ -export function createSettingsScreen(onBack: () => void): HTMLElement { + * game is (re)started (GameCanvas reads them once, on mount), plus a seed field, reachable from + * the main menu. + * @param currentSeed The running game's current PRNG seed (see GameStore.seed) — 0 if no game has + * started yet this page load. Shown as the seed field's starting value. + * @param onStartWithSeed Called with a new seed the instant the seed field's value is committed + * (blur or Enter) — starts (or restarts) a game seeded from it, discarding any existing save, the + * same way "Новая игра" does. */ +export function createSettingsScreen(currentSeed: number, onBack: () => void, onStartWithSeed: (seed: number) => void): HTMLElement { const root = document.createElement('div'); root.className = 'main-menu'; @@ -45,13 +51,34 @@ export function createSettingsScreen(onBack: () => void): HTMLElement { setRenderSettings({ noScale: value }); }); + const seedLabel = document.createElement('label'); + seedLabel.className = 'settings-seed-label'; + seedLabel.textContent = 'Сид игры (новая игра при изменении)'; + + const seedInput = document.createElement('input'); + seedInput.type = 'number'; + seedInput.className = 'settings-seed-input'; + seedInput.min = '0'; + seedInput.max = '4294967295'; + seedInput.step = '1'; + seedInput.value = String(currentSeed); + seedInput.addEventListener('change', () => { + const parsed = Math.floor(Number(seedInput.value)); + if (!Number.isFinite(parsed) || parsed < 0) { + seedInput.value = String(currentSeed); + return; + } + onStartWithSeed(parsed >>> 0); + }); + seedLabel.appendChild(seedInput); + const backBtn = document.createElement('button'); backBtn.type = 'button'; backBtn.className = 'menu-btn'; backBtn.textContent = 'Назад'; backBtn.addEventListener('click', onBack); - buttons.append(antialiasingBtn, noScaleBtn, backBtn); + buttons.append(antialiasingBtn, noScaleBtn, seedLabel, backBtn); root.append(title, buttons); return root; } diff --git a/src/game/GameStore.ts b/src/game/GameStore.ts index 41e0294..673064d 100644 --- a/src/game/GameStore.ts +++ b/src/game/GameStore.ts @@ -17,6 +17,7 @@ import { RABBIT_STRIKE_RANGE, rabbitsForDay, } from './rabbits'; +import { Random, randomSeed } from './random'; import { pickSpawnPositions } from './spawning'; import type { Inventory, ResourceType, Vec2 } from './types'; import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades'; @@ -69,6 +70,9 @@ export class GameStore { /** Seconds left to show the "КРОВАВАЯ ЛУНА!" banner; sums to a boolean by `bloodMoonBannerVisible`. */ bloodMoonBannerTimer = 0; upgradeLevels: Record = { ...DEFAULT_UPGRADE_LEVELS }; + /** This playthrough's PRNG seed — shown/settable in Settings (see SettingsScreen), persisted so + * a save's random sequence is reproducible across a reload. */ + seed = 0; // This life's run-summary stats, shown on the death screen — reset to 0 on a fresh game, // restored from the save when continuing one (see initialize()). @@ -96,6 +100,13 @@ export class GameStore { private lastAnnouncedBloodMoonDay = -1; /** Map dimensions from the last initialize(), reused to grow each new day's nodes. */ private mapSize: { width: number; height: number } | null = null; + /** Every gameplay-affecting random decision goes through this instead of Math.random() — see + * random.ts. (Re)created fresh in initialize(), seeded from `seed`. */ + private random: Random = new Random(0); + /** Set by setNextSeed() to override the seed the *next* initialize() picks (a fresh random one, + * or the save's) — used when Settings starts a new game with an explicit seed. Consumed (reset + * to null) the instant initialize() reads it. */ + private pendingSeed: number | null = null; private readonly analytics: Analytics; @@ -258,6 +269,12 @@ export class GameStore { return true; } + /** Pins the seed the *next* initialize() call will use, overriding both a fresh random seed and + * any existing save's seed — used when Settings starts a new game with an explicit seed. */ + setNextSeed(seed: number) { + this.pendingSeed = seed; + } + /** (Re)initializes the game for a map of the given size. Restores a saved game (character/nodes/ * drops/progress) if one exists; otherwise places the character in the center and scatters day * 1's trees and bushes. */ @@ -276,6 +293,14 @@ export class GameStore { this.day = save?.day ?? 1; this.hour = save?.hour ?? 12; + // An explicit seed (from Settings) wins over a saved game's own seed, which in turn wins over + // a fresh random one — a save's rngState resumes its exact sequence, but only when its seed + // wasn't just overridden out from under it. + const explicitSeed = this.pendingSeed; + this.pendingSeed = null; + this.seed = explicitSeed ?? save?.seed ?? randomSeed(); + this.random = new Random(explicitSeed === null ? save?.rngState ?? this.seed : this.seed); + // Always reset to defaults first (not just `if (save)`) — the store instance is reused across // "Новая игра" clicks, so without this a fresh game would inherit upgrade levels bought during // the previous playthrough instead of starting back at level 1. Restored before the world @@ -353,14 +378,16 @@ export class GameStore { rabbitsCaughtCount: this.rabbitsCaughtCount, totalCoinsEarned: this.totalCoinsEarned, playTimeSeconds: this.playTimeSeconds, + seed: this.seed, + rngState: this.random.getState(), }); } /** Scatters day 1's trees and bushes, keeping every node clear of the character's starting spot, * the map edge, and each other regardless of kind. */ private spawnNodes(width: number, height: number): ResourceNode[] { - const treePositions = pickSpawnPositions(treesForDay(this.day), width, height, []); - const bushPositions = pickSpawnPositions(bushesForDay(this.day), width, height, treePositions); + const treePositions = pickSpawnPositions(treesForDay(this.day), width, height, [], this.random); + const bushPositions = pickSpawnPositions(bushesForDay(this.day), width, height, treePositions, this.random); return [ ...treePositions.map((pos) => ResourceNode.spawnNew('tree', pos)), ...bushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)), @@ -378,8 +405,8 @@ export class GameStore { const treeDeficit = Math.max(0, treesForDay(this.day) - this.nodes.filter((n) => n.kind === 'tree').length); const bushDeficit = Math.max(0, bushesForDay(this.day) - this.nodes.filter((n) => n.kind === 'bush').length); - const newTreePositions = pickSpawnPositions(treeDeficit, width, height, existing); - const newBushPositions = pickSpawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions]); + const newTreePositions = pickSpawnPositions(treeDeficit, width, height, existing, this.random); + const newBushPositions = pickSpawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions], this.random); const newNodes = [ ...newTreePositions.map((pos) => ResourceNode.spawnNew('tree', pos)), ...newBushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)), @@ -390,8 +417,8 @@ export class GameStore { } private spawnRabbits(width: number, height: number, count: number): Rabbit[] { - const positions = pickSpawnPositions(count, width, height, []); - return positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp)); + const positions = pickSpawnPositions(count, width, height, [], this.random); + return positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp, this.random)); } /** Tops the rabbit population up to this day's cap (never removes any — the cap only ever @@ -405,8 +432,8 @@ export class GameStore { return; const existing = this.rabbits.map((rabbit): Vec2 => ({ x: rabbit.x, y: rabbit.y })); - const positions = pickSpawnPositions(deficit, width, height, existing); - this.rabbits = [...this.rabbits, ...positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp))]; + const positions = pickSpawnPositions(deficit, width, height, existing, this.random); + this.rabbits = [...this.rabbits, ...positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp, this.random))]; } holdNode(id: string) { @@ -576,6 +603,7 @@ export class GameStore { mapWidth: this.mapSize.width, mapHeight: this.mapSize.height, onStrike: () => this.landRabbitStrike(), + random: this.random, }; for (const rabbit of this.rabbits) { rabbit.regenerateHealth(deltaTime, RABBIT_HEALTH_REGEN_INTERVAL, maxHp); @@ -694,7 +722,7 @@ export class GameStore { } private ejectDrop(origin: Vec2, resource: ResourceType) { - this.resourceDrops.push(ResourceDrop.eject(origin, resource)); + this.resourceDrops.push(ResourceDrop.eject(origin, resource, this.random)); } /** Advances every resource drop currently in a ballistic flight (falling from a node, or flying diff --git a/src/game/entities/Rabbit.ts b/src/game/entities/Rabbit.ts index 4b1f0d9..c6be25f 100644 --- a/src/game/entities/Rabbit.ts +++ b/src/game/entities/Rabbit.ts @@ -1,6 +1,7 @@ import { makeAutoObservable } from 'mobx'; import { ENTITY_EDGE_MARGIN } from '../constants'; import { randomId } from '../id'; +import type { Random } from '../random'; import { RABBIT_ACTION_DURATION, RABBIT_JUMP_ARC_HEIGHT, @@ -24,6 +25,9 @@ export interface RabbitTickContext { /** Called the instant a strike begins — damage lands at the *start* of the action, not once it * finishes playing out. */ onStrike: () => void; + /** The game's seeded PRNG (see GameStore) — every random jump-direction decision goes through + * this instead of Math.random(), so a whole playthrough is reproducible from its seed. */ + random: Random; } /** A rabbit: hops in random directions by day (never too close to the character), and hunts them @@ -82,8 +86,8 @@ export class Rabbit { /** Builds a fresh rabbit at `pos`, full hp, staggered mid-pause so freshly spawned rabbits don't * all start their first action in lockstep. */ - static spawnNew(pos: Vec2, maxHp: number): Rabbit { - return new Rabbit(pos.x, pos.y, maxHp, 'idle', Math.random() * RABBIT_PAUSE_DURATION); + static spawnNew(pos: Vec2, maxHp: number, random: Random): Rabbit { + return new Rabbit(pos.x, pos.y, maxHp, 'idle', random.next() * RABBIT_PAUSE_DURATION); } /** Reconstructs a rabbit from saved data. Every field gets an explicit fallback since a save @@ -211,7 +215,7 @@ export class Rabbit { // overshooting past them. const towardCharacter = Math.atan2(ctx.characterPos.y - this.y, ctx.characterPos.x - this.x); const spread = RABBIT_NIGHT_JUMP_SPREAD_DEGREES * (Math.PI / 180); - angle = towardCharacter + (Math.random() * 2 - 1) * spread; + angle = towardCharacter + (ctx.random.next() * 2 - 1) * spread; jumpDistance = Math.min(RABBIT_JUMP_DISTANCE, distanceToCharacter); } else { angle = this.pickDayJumpAngle(ctx); @@ -241,7 +245,7 @@ export class Rabbit { return awayAngle; for (let attempt = 0; attempt < 8; attempt++) { - const angle = Math.random() * Math.PI * 2; + const angle = ctx.random.next() * Math.PI * 2; const targetX = this.x + Math.cos(angle) * RABBIT_JUMP_DISTANCE; const targetY = this.y + Math.sin(angle) * RABBIT_JUMP_DISTANCE; const distanceToCharacter = Math.hypot(ctx.characterPos.x - targetX, ctx.characterPos.y - targetY); diff --git a/src/game/entities/ResourceDrop.ts b/src/game/entities/ResourceDrop.ts index 17a5169..67576fc 100644 --- a/src/game/entities/ResourceDrop.ts +++ b/src/game/entities/ResourceDrop.ts @@ -7,6 +7,7 @@ import { DROP_FALL_DURATION, } from '../constants'; import { randomId } from '../id'; +import type { Random } from '../random'; import type { DropPhase, Flight, ResourceDropData, ResourceType, Vec2 } from '../types'; /** A resource in flight — ejected from a chopped node or a killed rabbit, falling to a landing @@ -35,9 +36,9 @@ export class ResourceDrop { /** Ejects a resource from `origin` on a ballistic arc to a random spot within a circle around * it; it only becomes pickable once it lands (see updateFlight). */ - static eject(origin: Vec2, resource: ResourceType): ResourceDrop { - const angle = Math.random() * Math.PI * 2; - const landDistance = Math.random() * DROP_EJECT_RADIUS; + static eject(origin: Vec2, resource: ResourceType, random: Random): ResourceDrop { + const angle = random.next() * Math.PI * 2; + const landDistance = random.next() * DROP_EJECT_RADIUS; const toX = origin.x + Math.cos(angle) * landDistance; const toY = origin.y + Math.sin(angle) * landDistance; return new ResourceDrop(resource, origin.x, origin.y, 0, 'falling', { diff --git a/src/game/persistence.ts b/src/game/persistence.ts index f408e98..577fc05 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -32,6 +32,13 @@ export interface SaveData { /** Real seconds elapsed this life (ticked deltaTime, not in-game clock time) — stops * accumulating once dead, same as the other life stats above. */ playTimeSeconds?: number; + /** The seed this playthrough's PRNG (see game/random.ts) was started from — absent on saves from + * before it existed, which get a fresh random one instead (see GameStore.initialize). */ + seed?: number; + /** The PRNG's internal state as of this save, so continuing resumes its exact random sequence + * instead of replaying from the start of `seed`'s sequence again. Absent on saves from before it + * existed (or from a version mismatch), which fall back to `seed` itself. */ + rngState?: number; } function isValidSave(value: unknown): value is SaveData { diff --git a/src/game/random.ts b/src/game/random.ts new file mode 100644 index 0000000..cd56197 --- /dev/null +++ b/src/game/random.ts @@ -0,0 +1,39 @@ +/** A small, fast, seedable PRNG (mulberry32) — same `next()` contract as `Math.random()` (a float + * in [0, 1)), but deterministic: the same seed always produces the same sequence. Every gameplay- + * affecting random decision (node/rabbit spawn positions, jump directions, drop landing spots — + * see GameStore's own `random` field) goes through an instance of this instead of `Math.random()` + * directly, so a whole playthrough is reproducible from its seed alone. (Entity id strings — see + * id.ts — are the one exception: they're just internal bookkeeping labels, never read by gameplay + * logic, so they're left on plain `Math.random()` rather than threading a Random instance through + * every entity constructor for no gameplay-visible benefit.) */ +export class Random { + private state: number; + + constructor(seed: number) { + // >>> 0 folds any float/negative/out-of-range input down to a valid 32-bit unsigned seed. + this.state = seed >>> 0; + } + + /** A float in [0, 1), same contract as Math.random(). */ + next(): number { + this.state = (this.state + 0x6d2b79f5) | 0; + let t = Math.imul(this.state ^ (this.state >>> 15), this.state | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + /** The PRNG's current internal state — save this (not just the original seed) to resume the + * exact same random sequence later, e.g. across a save/continue (see GameStore), rather than + * replaying from the very start of the seed's sequence again. */ + getState(): number { + return this.state; + } +} + +/** Generates a fresh seed for a new game — the one place actual (non-reproducible) randomness is + * still used, since a *new* game is supposed to be different each time by default (see + * GameStore.initialize). Settings lets a player pin a specific seed instead — see + * game/seedSettings.ts. */ +export function randomSeed(): number { + return Math.floor(Math.random() * 0xffffffff); +} diff --git a/src/game/spawning.ts b/src/game/spawning.ts index d0749e6..de6e9a3 100644 --- a/src/game/spawning.ts +++ b/src/game/spawning.ts @@ -1,10 +1,11 @@ import { ENTITY_EDGE_MARGIN, ENTITY_MIN_DISTANCE_BETWEEN, ENTITY_MIN_DISTANCE_FROM_CENTER } from './constants'; +import type { Random } from './random'; import type { Vec2 } from './types'; /** Scatters up to `count` new positions across a `width` x `height` map, keeping clear of the map * center (the character's spawn point), the map edges, and every existing/already-picked position * — shared by nodes and rabbits alike, which all use the same spacing rules. */ -export function pickSpawnPositions(count: number, width: number, height: number, existing: Vec2[]): Vec2[] { +export function pickSpawnPositions(count: number, width: number, height: number, existing: Vec2[], random: Random): Vec2[] { const centerX = width / 2; const centerY = height / 2; const positions: Vec2[] = []; @@ -12,8 +13,8 @@ export function pickSpawnPositions(count: number, width: number, height: number, while (positions.length < count && attempts < 1000) { attempts++; - const x = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, width - ENTITY_EDGE_MARGIN * 2); - const y = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, height - ENTITY_EDGE_MARGIN * 2); + const x = ENTITY_EDGE_MARGIN + random.next() * Math.max(0, width - ENTITY_EDGE_MARGIN * 2); + const y = ENTITY_EDGE_MARGIN + random.next() * Math.max(0, height - ENTITY_EDGE_MARGIN * 2); if (Math.hypot(x - centerX, y - centerY) < ENTITY_MIN_DISTANCE_FROM_CENTER) continue;