prnd with world seed
This commit is contained in:
+22
@@ -111,6 +111,28 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.main-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|||||||
+10
-2
@@ -53,7 +53,11 @@ export class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showSettings(): void {
|
private showSettings(): void {
|
||||||
const settings = createSettingsScreen(() => this.showMenu());
|
const settings = createSettingsScreen(
|
||||||
|
this.store.seed,
|
||||||
|
() => this.showMenu(),
|
||||||
|
(seed) => this.handleNewGame(seed),
|
||||||
|
);
|
||||||
this.root.replaceChildren(settings);
|
this.root.replaceChildren(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +73,9 @@ export class App {
|
|||||||
this.gameScreen.mount();
|
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
|
// 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
|
// 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
|
// records its own failure at the moment it happens (see GameStore.landRabbitStrike) and clears
|
||||||
@@ -77,6 +83,8 @@ export class App {
|
|||||||
if (hasSave())
|
if (hasSave())
|
||||||
this.analytics.death('restarted');
|
this.analytics.death('restarted');
|
||||||
clearSave();
|
clearSave();
|
||||||
|
if (seed !== undefined)
|
||||||
|
this.store.setNextSeed(seed);
|
||||||
this.showGame();
|
this.showGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/** 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. */
|
* game is (re)started (GameCanvas reads them once, on mount), plus a seed field, reachable from
|
||||||
export function createSettingsScreen(onBack: () => void): HTMLElement {
|
* 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');
|
const root = document.createElement('div');
|
||||||
root.className = 'main-menu';
|
root.className = 'main-menu';
|
||||||
|
|
||||||
@@ -45,13 +51,34 @@ export function createSettingsScreen(onBack: () => void): HTMLElement {
|
|||||||
setRenderSettings({ noScale: value });
|
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');
|
const backBtn = document.createElement('button');
|
||||||
backBtn.type = 'button';
|
backBtn.type = 'button';
|
||||||
backBtn.className = 'menu-btn';
|
backBtn.className = 'menu-btn';
|
||||||
backBtn.textContent = 'Назад';
|
backBtn.textContent = 'Назад';
|
||||||
backBtn.addEventListener('click', onBack);
|
backBtn.addEventListener('click', onBack);
|
||||||
|
|
||||||
buttons.append(antialiasingBtn, noScaleBtn, backBtn);
|
buttons.append(antialiasingBtn, noScaleBtn, seedLabel, backBtn);
|
||||||
root.append(title, buttons);
|
root.append(title, buttons);
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-9
@@ -17,6 +17,7 @@ import {
|
|||||||
RABBIT_STRIKE_RANGE,
|
RABBIT_STRIKE_RANGE,
|
||||||
rabbitsForDay,
|
rabbitsForDay,
|
||||||
} from './rabbits';
|
} from './rabbits';
|
||||||
|
import { Random, randomSeed } from './random';
|
||||||
import { pickSpawnPositions } from './spawning';
|
import { pickSpawnPositions } from './spawning';
|
||||||
import type { Inventory, ResourceType, Vec2 } from './types';
|
import type { Inventory, ResourceType, Vec2 } from './types';
|
||||||
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
|
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`. */
|
/** Seconds left to show the "КРОВАВАЯ ЛУНА!" banner; sums to a boolean by `bloodMoonBannerVisible`. */
|
||||||
bloodMoonBannerTimer = 0;
|
bloodMoonBannerTimer = 0;
|
||||||
upgradeLevels: Record<UpgradeId, number> = { ...DEFAULT_UPGRADE_LEVELS };
|
upgradeLevels: Record<UpgradeId, number> = { ...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,
|
// 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()).
|
// restored from the save when continuing one (see initialize()).
|
||||||
@@ -96,6 +100,13 @@ export class GameStore {
|
|||||||
private lastAnnouncedBloodMoonDay = -1;
|
private lastAnnouncedBloodMoonDay = -1;
|
||||||
/** Map dimensions from the last initialize(), reused to grow each new day's nodes. */
|
/** Map dimensions from the last initialize(), reused to grow each new day's nodes. */
|
||||||
private mapSize: { width: number; height: number } | null = null;
|
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;
|
private readonly analytics: Analytics;
|
||||||
|
|
||||||
@@ -258,6 +269,12 @@ export class GameStore {
|
|||||||
return true;
|
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/
|
/** (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
|
* drops/progress) if one exists; otherwise places the character in the center and scatters day
|
||||||
* 1's trees and bushes. */
|
* 1's trees and bushes. */
|
||||||
@@ -276,6 +293,14 @@ export class GameStore {
|
|||||||
this.day = save?.day ?? 1;
|
this.day = save?.day ?? 1;
|
||||||
this.hour = save?.hour ?? 12;
|
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
|
// 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
|
// "Новая игра" 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
|
// the previous playthrough instead of starting back at level 1. Restored before the world
|
||||||
@@ -353,14 +378,16 @@ export class GameStore {
|
|||||||
rabbitsCaughtCount: this.rabbitsCaughtCount,
|
rabbitsCaughtCount: this.rabbitsCaughtCount,
|
||||||
totalCoinsEarned: this.totalCoinsEarned,
|
totalCoinsEarned: this.totalCoinsEarned,
|
||||||
playTimeSeconds: this.playTimeSeconds,
|
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,
|
/** 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. */
|
* the map edge, and each other regardless of kind. */
|
||||||
private spawnNodes(width: number, height: number): ResourceNode[] {
|
private spawnNodes(width: number, height: number): ResourceNode[] {
|
||||||
const treePositions = pickSpawnPositions(treesForDay(this.day), width, height, []);
|
const treePositions = pickSpawnPositions(treesForDay(this.day), width, height, [], this.random);
|
||||||
const bushPositions = pickSpawnPositions(bushesForDay(this.day), width, height, treePositions);
|
const bushPositions = pickSpawnPositions(bushesForDay(this.day), width, height, treePositions, this.random);
|
||||||
return [
|
return [
|
||||||
...treePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
|
...treePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
|
||||||
...bushPositions.map((pos) => ResourceNode.spawnNew('bush', 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 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 bushDeficit = Math.max(0, bushesForDay(this.day) - this.nodes.filter((n) => n.kind === 'bush').length);
|
||||||
|
|
||||||
const newTreePositions = pickSpawnPositions(treeDeficit, width, height, existing);
|
const newTreePositions = pickSpawnPositions(treeDeficit, width, height, existing, this.random);
|
||||||
const newBushPositions = pickSpawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions]);
|
const newBushPositions = pickSpawnPositions(bushDeficit, width, height, [...existing, ...newTreePositions], this.random);
|
||||||
const newNodes = [
|
const newNodes = [
|
||||||
...newTreePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
|
...newTreePositions.map((pos) => ResourceNode.spawnNew('tree', pos)),
|
||||||
...newBushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)),
|
...newBushPositions.map((pos) => ResourceNode.spawnNew('bush', pos)),
|
||||||
@@ -390,8 +417,8 @@ export class GameStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private spawnRabbits(width: number, height: number, count: number): Rabbit[] {
|
private spawnRabbits(width: number, height: number, count: number): Rabbit[] {
|
||||||
const positions = pickSpawnPositions(count, width, height, []);
|
const positions = pickSpawnPositions(count, width, height, [], this.random);
|
||||||
return positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp));
|
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
|
/** 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;
|
return;
|
||||||
|
|
||||||
const existing = this.rabbits.map((rabbit): Vec2 => ({ x: rabbit.x, y: rabbit.y }));
|
const existing = this.rabbits.map((rabbit): Vec2 => ({ x: rabbit.x, y: rabbit.y }));
|
||||||
const positions = pickSpawnPositions(deficit, width, height, existing);
|
const positions = pickSpawnPositions(deficit, width, height, existing, this.random);
|
||||||
this.rabbits = [...this.rabbits, ...positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp))];
|
this.rabbits = [...this.rabbits, ...positions.map((pos) => Rabbit.spawnNew(pos, this.rabbitMaxHp, this.random))];
|
||||||
}
|
}
|
||||||
|
|
||||||
holdNode(id: string) {
|
holdNode(id: string) {
|
||||||
@@ -576,6 +603,7 @@ export class GameStore {
|
|||||||
mapWidth: this.mapSize.width,
|
mapWidth: this.mapSize.width,
|
||||||
mapHeight: this.mapSize.height,
|
mapHeight: this.mapSize.height,
|
||||||
onStrike: () => this.landRabbitStrike(),
|
onStrike: () => this.landRabbitStrike(),
|
||||||
|
random: this.random,
|
||||||
};
|
};
|
||||||
for (const rabbit of this.rabbits) {
|
for (const rabbit of this.rabbits) {
|
||||||
rabbit.regenerateHealth(deltaTime, RABBIT_HEALTH_REGEN_INTERVAL, maxHp);
|
rabbit.regenerateHealth(deltaTime, RABBIT_HEALTH_REGEN_INTERVAL, maxHp);
|
||||||
@@ -694,7 +722,7 @@ export class GameStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ejectDrop(origin: Vec2, resource: ResourceType) {
|
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
|
/** Advances every resource drop currently in a ballistic flight (falling from a node, or flying
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { makeAutoObservable } from 'mobx';
|
import { makeAutoObservable } from 'mobx';
|
||||||
import { ENTITY_EDGE_MARGIN } from '../constants';
|
import { ENTITY_EDGE_MARGIN } from '../constants';
|
||||||
import { randomId } from '../id';
|
import { randomId } from '../id';
|
||||||
|
import type { Random } from '../random';
|
||||||
import {
|
import {
|
||||||
RABBIT_ACTION_DURATION,
|
RABBIT_ACTION_DURATION,
|
||||||
RABBIT_JUMP_ARC_HEIGHT,
|
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
|
/** Called the instant a strike begins — damage lands at the *start* of the action, not once it
|
||||||
* finishes playing out. */
|
* finishes playing out. */
|
||||||
onStrike: () => void;
|
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
|
/** 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
|
/** Builds a fresh rabbit at `pos`, full hp, staggered mid-pause so freshly spawned rabbits don't
|
||||||
* all start their first action in lockstep. */
|
* all start their first action in lockstep. */
|
||||||
static spawnNew(pos: Vec2, maxHp: number): Rabbit {
|
static spawnNew(pos: Vec2, maxHp: number, random: Random): Rabbit {
|
||||||
return new Rabbit(pos.x, pos.y, maxHp, 'idle', Math.random() * RABBIT_PAUSE_DURATION);
|
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
|
/** 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.
|
// overshooting past them.
|
||||||
const towardCharacter = Math.atan2(ctx.characterPos.y - this.y, ctx.characterPos.x - this.x);
|
const towardCharacter = Math.atan2(ctx.characterPos.y - this.y, ctx.characterPos.x - this.x);
|
||||||
const spread = RABBIT_NIGHT_JUMP_SPREAD_DEGREES * (Math.PI / 180);
|
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);
|
jumpDistance = Math.min(RABBIT_JUMP_DISTANCE, distanceToCharacter);
|
||||||
} else {
|
} else {
|
||||||
angle = this.pickDayJumpAngle(ctx);
|
angle = this.pickDayJumpAngle(ctx);
|
||||||
@@ -241,7 +245,7 @@ export class Rabbit {
|
|||||||
return awayAngle;
|
return awayAngle;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 8; attempt++) {
|
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 targetX = this.x + Math.cos(angle) * RABBIT_JUMP_DISTANCE;
|
||||||
const targetY = this.y + Math.sin(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);
|
const distanceToCharacter = Math.hypot(ctx.characterPos.x - targetX, ctx.characterPos.y - targetY);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DROP_FALL_DURATION,
|
DROP_FALL_DURATION,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { randomId } from '../id';
|
import { randomId } from '../id';
|
||||||
|
import type { Random } from '../random';
|
||||||
import type { DropPhase, Flight, ResourceDropData, ResourceType, Vec2 } from '../types';
|
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
|
/** 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
|
/** 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). */
|
* it; it only becomes pickable once it lands (see updateFlight). */
|
||||||
static eject(origin: Vec2, resource: ResourceType): ResourceDrop {
|
static eject(origin: Vec2, resource: ResourceType, random: Random): ResourceDrop {
|
||||||
const angle = Math.random() * Math.PI * 2;
|
const angle = random.next() * Math.PI * 2;
|
||||||
const landDistance = Math.random() * DROP_EJECT_RADIUS;
|
const landDistance = random.next() * DROP_EJECT_RADIUS;
|
||||||
const toX = origin.x + Math.cos(angle) * landDistance;
|
const toX = origin.x + Math.cos(angle) * landDistance;
|
||||||
const toY = origin.y + Math.sin(angle) * landDistance;
|
const toY = origin.y + Math.sin(angle) * landDistance;
|
||||||
return new ResourceDrop(resource, origin.x, origin.y, 0, 'falling', {
|
return new ResourceDrop(resource, origin.x, origin.y, 0, 'falling', {
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ export interface SaveData {
|
|||||||
/** Real seconds elapsed this life (ticked deltaTime, not in-game clock time) — stops
|
/** Real seconds elapsed this life (ticked deltaTime, not in-game clock time) — stops
|
||||||
* accumulating once dead, same as the other life stats above. */
|
* accumulating once dead, same as the other life stats above. */
|
||||||
playTimeSeconds?: number;
|
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 {
|
function isValidSave(value: unknown): value is SaveData {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { ENTITY_EDGE_MARGIN, ENTITY_MIN_DISTANCE_BETWEEN, ENTITY_MIN_DISTANCE_FROM_CENTER } from './constants';
|
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';
|
import type { Vec2 } from './types';
|
||||||
|
|
||||||
/** Scatters up to `count` new positions across a `width` x `height` map, keeping clear of the map
|
/** 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
|
* 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. */
|
* — 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 centerX = width / 2;
|
||||||
const centerY = height / 2;
|
const centerY = height / 2;
|
||||||
const positions: Vec2[] = [];
|
const positions: Vec2[] = [];
|
||||||
@@ -12,8 +13,8 @@ export function pickSpawnPositions(count: number, width: number, height: number,
|
|||||||
|
|
||||||
while (positions.length < count && attempts < 1000) {
|
while (positions.length < count && attempts < 1000) {
|
||||||
attempts++;
|
attempts++;
|
||||||
const x = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, width - ENTITY_EDGE_MARGIN * 2);
|
const x = ENTITY_EDGE_MARGIN + random.next() * Math.max(0, width - ENTITY_EDGE_MARGIN * 2);
|
||||||
const y = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, height - 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)
|
if (Math.hypot(x - centerX, y - centerY) < ENTITY_MIN_DISTANCE_FROM_CENTER)
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user