fertig-classic-games/src/games/bookwork/BookworkLogic.js

162 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export const GRID_SIZE = 5;
// Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed)
export const LETTER_WEIGHTS = {
A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2,
L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2,
X:1, Y:3, Z:1,
};
const LETTER_POOL = [];
for (const [l, w] of Object.entries(LETTER_WEIGHTS)) {
for (let i = 0; i < w; i++) LETTER_POOL.push(l);
}
export function randomLetter(rng = Math.random) {
return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)];
}
// Default spawn chances for multiplier tiles on refills (late-game floor).
export const SPECIAL_TILE_CHANCES = { gold: 0.03, diamond: 0.02 };
// Level schedule for multiplier-tile spawn chances. Interpolates linearly
// from cfg.start at cfg.startLevel down to cfg.end at cfg.endLevel, then
// holds cfg.end. Returns { goldChance, diamondChance }.
// cfg = { startLevel: 1, endLevel: 10,
// start: { gold: 0.10, diamond: 0.06 },
// end: { gold: 0.03, diamond: 0.02 } }
export function specialTileChances(level, cfg = {}) {
const start = cfg.start ?? SPECIAL_TILE_CHANCES;
const end = cfg.end ?? SPECIAL_TILE_CHANCES;
const from = cfg.startLevel ?? 1;
const to = cfg.endLevel ?? from;
const t = to > from ? Math.max(0, Math.min(1, (level - from) / (to - from))) : 0;
if (t <= 0) return { goldChance: start.gold, diamondChance: start.diamond };
if (t >= 1) return { goldChance: end.gold, diamondChance: end.diamond };
return {
goldChance: start.gold + (end.gold - start.gold) * t,
diamondChance: start.diamond + (end.diamond - start.diamond) * t,
};
}
export function makeGrid(rng = Math.random) {
const grid = [];
for (let r = 0; r < GRID_SIZE; r++) {
grid.push([]);
for (let c = 0; c < GRID_SIZE; c++) {
grid[r].push({ letter: randomLetter(rng), type: 'normal' });
}
}
return grid;
}
export function getAdjacent(r, c) {
const out = [];
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < GRID_SIZE && nc >= 0 && nc < GRID_SIZE) {
out.push({ r: nr, c: nc });
}
}
}
return out;
}
export function isAdjacent(a, b) {
return Math.abs(a.r - b.r) <= 1 && Math.abs(a.c - b.c) <= 1 && !(a.r === b.r && a.c === b.c);
}
export function wordFromCells(grid, cells) {
return cells.map(({ r, c }) => grid[r][c].letter).join('');
}
// Damage by word length; gold = 1.5×, diamond = 2× (stacking)
const DMG_BY_LEN = [0, 0, 0, 1, 2, 4, 7, 11, 15];
export function computeDamage(cells, grid) {
const len = cells.length;
const base = len < DMG_BY_LEN.length ? DMG_BY_LEN[len] : 15 + (len - 8) * 3;
let mult = 1;
for (const { r, c } of cells) {
const t = grid[r][c].type;
if (t === 'gold') mult *= 1.5;
if (t === 'diamond') mult *= 2;
}
return Math.max(1, Math.round(base * mult));
}
// Fire tile self-damage: 5 per fire tile when word length < 5
export function computeSelfDamage(cells, grid) {
if (cells.length >= 5) return 0;
return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5;
}
// Cascade tiles down in each column, fill top with new random tiles.
// opts may override spawn chances: { goldChance, diamondChance }.
export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) {
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
for (let c = 0; c < GRID_SIZE; c++) {
// Collect surviving tiles from bottom to top
const survive = [];
for (let r = GRID_SIZE - 1; r >= 0; r--) {
if (!used.has(`${r},${c}`)) survive.push({ ...next[r][c] });
}
// Fill remainder with new normal tiles
while (survive.length < GRID_SIZE) {
const gold = rng() < goldChance;
const diamond = !gold && rng() < diamondChance;
const type = gold ? 'gold' : diamond ? 'diamond' : 'normal';
survive.push({ letter: randomLetter(rng), type });
}
// Assign back: survive[0] = bottom row
for (let r = GRID_SIZE - 1; r >= 0; r--) {
next[r][c] = survive[GRID_SIZE - 1 - r];
}
}
return next;
}
export function dropSpecialTile(grid, type, rng = Math.random) {
const normals = [];
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (grid[r][c].type === 'normal') normals.push({ r, c });
}
}
if (!normals.length) return grid;
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
const { r, c } = normals[Math.floor(rng() * normals.length)];
next[r][c] = { ...next[r][c], type };
return next;
}
export function countPoisonTiles(grid) {
let n = 0;
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (grid[r][c].type === 'poison') n++;
}
}
return n;
}
// Compute player max HP from config + levelsCompleted
export function computeMaxHp(config, levelsCompleted) {
const base = config.playerBaseHp ?? 100;
const bonus = (config.milestones ?? [])
.filter((m) => levelsCompleted >= m.afterLevel)
.reduce((sum, m) => sum + (m.maxHpBonus ?? 0), 0);
return base + bonus;
}
export function isPotionUnlocked(config, levelsCompleted) {
return (config.milestones ?? []).some(
(m) => m.unlock === 'potion' && levelsCompleted >= m.afterLevel,
);
}