SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
6.3 KB · 167 lines typescript
Raw Blame History
1import type { Rng } from "../rng";2import type { ArcadeGameDefinition, ArcadeOutcome } from "./types";34/**5 * GRID//BREAK — an 8×8 grid of energy blocks. The player fires a wave down a6 * column; every cluster (≥3 connected blocks of one colour) the wave touches7 * detonates, blocks fall, new ones drop in, and any new cluster detonates in a8 * chain. Bombs clear 3×3, line blocks clear a row, ×2 blocks double the step.9 */1011export interface GridbreakConfig {12  size: number;13  colors: number;14  /** Value per block by colour index, in bet units (× total bet / size²·k). */15  values: number[];16  weights: number[];17  specialChance: { bomb: number; line: number; x2: number };18  chainLadder: number[];19  minCluster: number;20  maxChains: number;21}2223export type Cell = { c: number; s?: "bomb" | "line" | "x2" };2425export interface GridStep {26  grid: Cell[][]; // [col][row], row 0 = top27  destroyed: [number, number][];28  specials: { type: string; at: [number, number] }[];29  chain: number;30  multiplier: number;31  stepValue: number;32  win: number;33}3435function drawCell(cfg: GridbreakConfig, rng: Rng): Cell {36  const c = rng.weighted(cfg.weights);37  const roll = rng.float();38  if (roll < cfg.specialChance.bomb) return { c, s: "bomb" };39  if (roll < cfg.specialChance.bomb + cfg.specialChance.line) return { c, s: "line" };40  if (roll < cfg.specialChance.bomb + cfg.specialChance.line + cfg.specialChance.x2) return { c, s: "x2" };41  return { c };42}4344function clusters(grid: Cell[][], size: number, minCluster: number): [number, number][][] {45  const seen = new Set<string>();46  const out: [number, number][][] = [];47  for (let x = 0; x < size; x++)48    for (let y = 0; y < size; y++) {49      const k = `${x}:${y}`;50      if (seen.has(k)) continue;51      const color = grid[x][y].c;52      const stack: [number, number][] = [[x, y]];53      const group: [number, number][] = [];54      seen.add(k);55      while (stack.length) {56        const [cx, cy] = stack.pop()!;57        group.push([cx, cy]);58        for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {59          const nx = cx + dx;60          const ny = cy + dy;61          if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue;62          const nk = `${nx}:${ny}`;63          if (seen.has(nk) || grid[nx][ny].c !== color) continue;64          seen.add(nk);65          stack.push([nx, ny]);66        }67      }68      if (group.length >= minCluster) out.push(group);69    }70  return out;71}7273export function resolveGridbreak(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: { column?: number }): ArcadeOutcome {74  const cfg = def.config as unknown as GridbreakConfig;75  const size = cfg.size;76  const column = Math.max(0, Math.min(size - 1, Math.round(inputRaw.column ?? Math.floor(size / 2))));77  const grid: Cell[][] = Array.from({ length: size }, () => Array.from({ length: size }, () => drawCell(cfg, rng)));78  const steps: GridStep[] = [];79  const features: string[] = [];80  let chain = 0;81  let totalUnits = 0;82  const unit = 1 / (size * size); // one block of value 1 ≈ 1/64 of the bet83  let firstWave = true;84  while (chain < cfg.maxChains) {85    const all = clusters(grid, size, cfg.minCluster);86    // The first wave only detonates clusters touching the fired column; chains detonate everything.87    const hit = firstWave ? all.filter((g) => g.some(([x]) => x === column)) : all;88    if (hit.length === 0) break;89    const destroyed = new Set<string>();90    const specials: GridStep["specials"] = [];91    let stepMult = 1;92    const push = (x: number, y: number) => {93      if (x >= 0 && y >= 0 && x < size && y < size) destroyed.add(`${x}:${y}`);94    };95    for (const g of hit) for (const [x, y] of g) push(x, y);96    // Specials inside destroyed blocks trigger.97    for (const k of [...destroyed]) {98      const [x, y] = k.split(":").map(Number);99      const cell = grid[x][y];100      if (cell.s === "bomb") {101        specials.push({ type: "bomb", at: [x, y] });102        for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) push(x + dx, y + dy);103        features.push("Bomb");104      } else if (cell.s === "line") {105        specials.push({ type: "line", at: [x, y] });106        for (let xx = 0; xx < size; xx++) push(xx, y);107        features.push("Line clear");108      } else if (cell.s === "x2") {109        specials.push({ type: "x2", at: [x, y] });110        stepMult *= 2;111        features.push("×2 block");112      }113    }114    const chainMult = cfg.chainLadder[Math.min(chain, cfg.chainLadder.length - 1)];115    let value = 0;116    const destroyedList: [number, number][] = [];117    for (const k of destroyed) {118      const [x, y] = k.split(":").map(Number);119      value += cfg.values[grid[x][y].c] * unit;120      destroyedList.push([x, y]);121    }122    const stepUnits = value * stepMult * chainMult;123    totalUnits += stepUnits;124    steps.push({125      grid: grid.map((c) => c.map((cell) => ({ ...cell }))),126      destroyed: destroyedList,127      specials,128      chain,129      multiplier: stepMult * chainMult,130      stepValue: Math.round(stepUnits * def.payScale * 10000) / 10000,131      win: Math.round(bet * stepUnits * def.payScale),132    });133    // Gravity + refill.134    for (let x = 0; x < size; x++) {135      const kept: Cell[] = [];136      for (let y = 0; y < size; y++) if (!destroyed.has(`${x}:${y}`)) kept.push(grid[x][y]);137      const fresh: Cell[] = [];138      while (fresh.length + kept.length < size) fresh.push(drawCell(cfg, rng));139      grid[x] = [...fresh, ...kept];140    }141    chain++;142    firstWave = false;143    if (chain > 1) features.push("Chain reaction");144  }145  // Final grid for presentation.146  steps.push({ grid: grid.map((c) => c.map((cell) => ({ ...cell }))), destroyed: [], specials: [], chain, multiplier: 1, stepValue: 0, win: 0 });147  const scaled = totalUnits * def.payScale;148  const capped = scaled > def.maxMultiplier;149  const multiplier = Math.min(def.maxMultiplier, scaled);150  const totalWin = Math.round(bet * multiplier);151  return {152    game: def.slug,153    version: def.version,154    bet,155    totalWin,156    multiplier: bet ? totalWin / bet : 0,157    capped,158    features: [...new Set(features)],159    steps,160    summary: { column, chains: chain, blocksDestroyed: steps.reduce((a, s) => a + s.destroyed.length, 0) },161  };162}163164export function randomGridbreakInput(def: ArcadeGameDefinition, rng: Rng): { column: number } {165  return { column: rng.int((def.config as unknown as GridbreakConfig).size) };166}167