refactor: tighten core seams and spawning
This commit is contained in:
@@ -28,6 +28,11 @@ export const OY = 80; // отступ комнаты сверху (м
|
||||
export const FIXED_FPS = 60;
|
||||
export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг
|
||||
|
||||
export const GAME_LOOP = {
|
||||
maxFrameTimeSec: 0.25, // не «отыгрывать» долгие паузы (фон/таб)
|
||||
maxStepsPerFrame: 5, // защита от «спирали смерти» при лагах
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Типы тайлов
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -88,6 +93,7 @@ export const PLAYER = {
|
||||
speed: 3.2, // px за шаг
|
||||
maxHp: 6,
|
||||
invFrames: 60, // неуязвимость после удара, в шагах
|
||||
entryInvFrames: 20, // короткая неуязвимость при входе в комнату, в шагах
|
||||
rangedCooldown: 10, // перезарядка выстрела, в шагах
|
||||
meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
|
||||
transitionLock: 15, // блок повторного перехода между комнатами, в шагах
|
||||
@@ -137,6 +143,7 @@ export const SPAWN = {
|
||||
minDistFromDoor: 180, // не спавнить ближе к двери входа
|
||||
minDistFromPlayer: 150,
|
||||
minDistBetween: 60,
|
||||
maxPlacementTries: 100,
|
||||
treasureChance: 0.12, // шанс комнаты-сокровищницы
|
||||
bossChance: 0.2, // шанс назначить комнату боссом
|
||||
};
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ export class Game {
|
||||
const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
|
||||
this.player.place(px, py);
|
||||
this.player.facing = fromDir;
|
||||
this.player.invTimer = 20; // короткая неуязвимость на входе
|
||||
this.player.invTimer = PLAYER.entryInvFrames;
|
||||
this.player.transCD = PLAYER.transitionLock;
|
||||
|
||||
this.meleeSwing = null;
|
||||
|
||||
+23
-3
@@ -12,9 +12,9 @@ export class Rng {
|
||||
private state: number;
|
||||
|
||||
/** Без seed — случайный старт; с seed — детерминированная цепочка. */
|
||||
constructor(seed?: number) {
|
||||
// 0 — валидный seed, поэтому проверяем именно на undefined.
|
||||
this.state = (seed === undefined ? (Math.random() * 2 ** 32) >>> 0 : seed) >>> 0;
|
||||
constructor(seed: number = autoSeed()) {
|
||||
// 0 — валидный seed; авто-seed включается только когда аргумент не передан.
|
||||
this.state = seed >>> 0;
|
||||
}
|
||||
|
||||
/** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
|
||||
@@ -43,6 +43,7 @@ export class Rng {
|
||||
|
||||
/** Случайный элемент массива. */
|
||||
pick<T>(arr: readonly T[]): T {
|
||||
if (arr.length === 0) throw new Error('Rng.pick: пустой массив');
|
||||
return arr[this.int(0, arr.length - 1)];
|
||||
}
|
||||
|
||||
@@ -55,3 +56,22 @@ export class Rng {
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
|
||||
const AUTO_SEED_STEP = 0x9e3779b9;
|
||||
let autoSeedCounter = 0;
|
||||
|
||||
/**
|
||||
* Seed для нового нефиксированного забега. Энтропия берётся только внутри Rng:
|
||||
* остальная игровая логика по-прежнему получает все случайные числа через next().
|
||||
*/
|
||||
function autoSeed(): number {
|
||||
autoSeedCounter = (autoSeedCounter + AUTO_SEED_STEP) >>> 0;
|
||||
let seed = Date.now() >>> 0;
|
||||
const crypto = globalThis.crypto;
|
||||
if (crypto?.getRandomValues) {
|
||||
const value = new Uint32Array(1);
|
||||
crypto.getRandomValues(value);
|
||||
seed ^= value[0];
|
||||
}
|
||||
return (seed ^ autoSeedCounter) >>> 0;
|
||||
}
|
||||
|
||||
+20
-10
@@ -6,6 +6,23 @@ import type { Dir, EnemyType } from '../types';
|
||||
import type { Rng } from '../rng';
|
||||
import { DEFAULT_RULES, type LevelRules } from '../rules';
|
||||
|
||||
function isSpawnSpotClear(
|
||||
x: number,
|
||||
y: number,
|
||||
doorX: number,
|
||||
doorY: number,
|
||||
playerX: number,
|
||||
playerY: number,
|
||||
enemies: readonly Enemy[],
|
||||
): boolean {
|
||||
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) return false;
|
||||
if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) return false;
|
||||
for (const e of enemies) {
|
||||
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подбирает врагов для комнаты и расставляет их так, чтобы они не появились
|
||||
* вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов
|
||||
@@ -43,19 +60,12 @@ export function spawnEnemies(
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let ok = false;
|
||||
for (let tries = 0; tries < 100 && !ok; tries++) {
|
||||
for (let tries = 0; tries < SPAWN.maxPlacementTries && !ok; tries++) {
|
||||
x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
|
||||
y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
|
||||
ok = true;
|
||||
|
||||
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) ok = false;
|
||||
else if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) ok = false;
|
||||
else {
|
||||
for (const e of enemies) {
|
||||
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) { ok = false; break; }
|
||||
}
|
||||
}
|
||||
ok = isSpawnSpotClear(x, y, doorX, doorY, playerX, playerY, enemies);
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
enemies.push(new Enemy(x, y, type, mods));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FIXED_DT } from '../config';
|
||||
import { FIXED_DT, GAME_LOOP } from '../config';
|
||||
import type { Game } from '../core/Game';
|
||||
import type { KeyboardController } from '../input/KeyboardController';
|
||||
import type { InputSource } from '../input/InputState';
|
||||
|
||||
/**
|
||||
* Игровой цикл с ФИКСИРОВАННЫМ шагом.
|
||||
@@ -15,11 +15,10 @@ export class GameLoop {
|
||||
private last = 0;
|
||||
private rafId = 0;
|
||||
private running = false;
|
||||
private readonly maxSteps = 5; // защита от «спирали смерти» при лагах
|
||||
|
||||
constructor(
|
||||
private readonly game: Game,
|
||||
private readonly controller: KeyboardController,
|
||||
private readonly controller: InputSource,
|
||||
private readonly onRender: (alpha: number) => void,
|
||||
) {}
|
||||
|
||||
@@ -31,16 +30,19 @@ export class GameLoop {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = 0;
|
||||
}
|
||||
|
||||
private frame = (now: number): void => {
|
||||
if (!this.running) return;
|
||||
this.rafId = requestAnimationFrame(this.frame);
|
||||
|
||||
let frameTime = (now - this.last) / 1000;
|
||||
this.last = now;
|
||||
if (frameTime > 0.25) frameTime = 0.25; // не «отыгрывать» долгие паузы (фон/таб)
|
||||
if (frameTime > GAME_LOOP.maxFrameTimeSec) frameTime = GAME_LOOP.maxFrameTimeSec;
|
||||
|
||||
// Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр.
|
||||
const input = this.controller.poll();
|
||||
@@ -48,12 +50,12 @@ export class GameLoop {
|
||||
|
||||
this.accumulator += frameTime;
|
||||
let steps = 0;
|
||||
while (this.accumulator >= FIXED_DT && steps < this.maxSteps) {
|
||||
while (this.accumulator >= FIXED_DT && steps < GAME_LOOP.maxStepsPerFrame) {
|
||||
this.game.step(input);
|
||||
this.accumulator -= FIXED_DT;
|
||||
steps++;
|
||||
}
|
||||
if (steps === this.maxSteps) this.accumulator = 0; // отстали — ресинхронизируемся
|
||||
if (steps === GAME_LOOP.maxStepsPerFrame) this.accumulator = 0; // отстали — ресинхронизируемся
|
||||
|
||||
const alpha = this.accumulator / FIXED_DT;
|
||||
this.onRender(alpha);
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface InputState {
|
||||
restart: boolean; // рестарт на экране конца игры (однократно)
|
||||
}
|
||||
|
||||
/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */
|
||||
export interface InputSource {
|
||||
poll(): InputState;
|
||||
}
|
||||
|
||||
/** Нейтральный снимок — ничего не нажато. */
|
||||
export function emptyInput(): InputState {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InputState } from './InputState';
|
||||
import type { InputSource, InputState } from './InputState';
|
||||
import type { Dir } from '../core/types';
|
||||
|
||||
/**
|
||||
@@ -14,7 +14,7 @@ import type { Dir } from '../core/types';
|
||||
* (смена оружия/рестарт). Раз в кадр вызывается poll(), который собирает
|
||||
* InputState и сбрасывает однократные флаги.
|
||||
*/
|
||||
export class KeyboardController {
|
||||
export class KeyboardController implements InputSource {
|
||||
private held = new Set<string>();
|
||||
private toggleWeaponEdge = false;
|
||||
private restartEdge = false;
|
||||
|
||||
Reference in New Issue
Block a user