feat(coloradodefense): add chain reactions, inter-wave recap, vector skyline, and curved CRT
- Chain reaction explosions: player kills trigger delayed secondary blasts that can destroy nearby missiles (bounded to one extra tier) - Inter-wave "COLORADO DEFENDED" recap animation showing surviving cities (+500 each) and leftover missiles (+25 each) with tallying sounds - Dynamic wave bonus: replaced flat SCORE_WAVE_BONUS with per-surviving-city and per-remaining-missile bonuses, emitted in waveComplete event data - Vector-style skyline: hand-authored Rocky Mountain ridgelines with snow caps, building presets for varied city skylines, wireframe silos/cities - Minimal VectorFont module for the recap banner text - Curved CRT overlay: WebGL barrel-distortion pipeline with chromatic aberration, sheen highlight, vignette, and pulse on impacts - Three new 8-bit SFX: explode, explode2 (chain), count (tally) - Updated verification tests for chain reactions, wave bonus breakdown
This commit is contained in:
parent
dadf8c28dc
commit
db1e36a42c
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -9,16 +9,41 @@ import {
|
|||
DEFAULT_CITIES, GROUND_Y, PALETTES, createGame, step, pickFiringBase,
|
||||
fireInterceptor, lerpPos, explosionRadius,
|
||||
} from './ColoradoDefenseLogic.js';
|
||||
import { drawVectorText } from './VectorFont.js';
|
||||
|
||||
const D = { bg: -2, ground: 0, fx: 2, ui: 30, overlay: 60 };
|
||||
const D = { bg: -2, mountains: -1, ground: 0, fx: 2, ui: 30, overlay: 60 };
|
||||
const BEST_KEY = 'coloradodefense-best';
|
||||
|
||||
// A couple of distinct skylines so the 6 cities don't look identical, picked
|
||||
// deterministically by ground slot.
|
||||
const BUILDING_PRESETS = [
|
||||
[{ w: 20, h: 34 }, { w: 16, h: 58 }, { w: 26, h: 26 }, { w: 18, h: 46 }],
|
||||
[{ w: 18, h: 48 }, { w: 24, h: 30 }, { w: 16, h: 62 }, { w: 22, h: 40 }],
|
||||
[{ w: 22, h: 40 }, { w: 18, h: 30 }, { w: 20, h: 56 }, { w: 16, h: 44 }],
|
||||
];
|
||||
|
||||
// Fixed, hand-authored Rocky-Mountain ridgelines — line art only, drawn once
|
||||
// and independent of the wave palette so it reads as constant scenery.
|
||||
const BACK_RIDGE = [
|
||||
[0, 620], [140, 560], [260, 600], [380, 520], [520, 580],
|
||||
[660, 500], [800, 560], [960, 480], [1100, 540], [1240, 500],
|
||||
[1380, 570], [1520, 510], [1660, 560], [1800, 520], [1920, 580],
|
||||
];
|
||||
const FRONT_RIDGE = [
|
||||
[0, 760], [120, 680], [280, 730], [420, 640], [560, 720],
|
||||
[700, 650], [860, 710], [1020, 630], [1180, 700], [1340, 640],
|
||||
[1500, 720], [1660, 660], [1820, 720], [1920, 700],
|
||||
];
|
||||
const SNOW_CAP_PEAKS = [3, 7, 11]; // BACK_RIDGE indices tall enough to catch snow
|
||||
|
||||
export default class ColoradoDefenseGame extends Phaser.Scene {
|
||||
constructor() { super('ColoradoDefenseGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'coloradodefense', name: 'Colorado Defense' };
|
||||
this.overlayUp = false;
|
||||
this._recapActive = false;
|
||||
this._pendingGameOver = null;
|
||||
this.state = null;
|
||||
this.C = PALETTES[0];
|
||||
}
|
||||
|
|
@ -33,10 +58,13 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
|
|||
this.bgRect = this.add
|
||||
.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, this.C.bgTop)
|
||||
.setDepth(D.bg);
|
||||
this.skylineG = this.add.graphics().setDepth(D.mountains);
|
||||
this.groundG = this.add.graphics().setDepth(D.ground);
|
||||
this.fxG = this.add.graphics().setDepth(D.fx);
|
||||
this.reticleG = this.add.graphics().setDepth(D.fx + 1);
|
||||
|
||||
this.drawSkyline();
|
||||
|
||||
const pool = this.cache.json.get('colorado-defense-cities')?.cities ?? DEFAULT_CITIES;
|
||||
this.state = createGame(pool, Date.now());
|
||||
|
||||
|
|
@ -74,9 +102,34 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
|
|||
if (!base) return;
|
||||
fireInterceptor(this.state, base, x, y);
|
||||
playSound(this, SFX.SCIFI_LAUNCH);
|
||||
this.drawGround();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Skyline (background mountains) ───────────────────────────────────────
|
||||
drawSkyline() {
|
||||
const g = this.skylineG;
|
||||
g.clear();
|
||||
|
||||
const drawRidge = (points, color, alpha, width) => {
|
||||
g.lineStyle(width, color, alpha);
|
||||
g.beginPath();
|
||||
g.moveTo(points[0][0], points[0][1]);
|
||||
for (let i = 1; i < points.length; i += 1) g.lineTo(points[i][0], points[i][1]);
|
||||
g.strokePath();
|
||||
};
|
||||
|
||||
drawRidge(BACK_RIDGE, 0x2b3a4a, 0.35, 2);
|
||||
drawRidge(FRONT_RIDGE, 0x3d5266, 0.45, 2);
|
||||
|
||||
g.lineStyle(1.5, 0xdce8f0, 0.4);
|
||||
for (const idx of SNOW_CAP_PEAKS) {
|
||||
const [px, py] = BACK_RIDGE[idx];
|
||||
g.lineBetween(px - 10, py + 8, px, py);
|
||||
g.lineBetween(px, py, px + 10, py + 8);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ground (silos + cities) ──────────────────────────────────────────────────
|
||||
drawGround() {
|
||||
const g = this.groundG;
|
||||
|
|
@ -98,42 +151,98 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// Glowing wireframe launcher — a hollow outlined silhouette (double-stroke
|
||||
// for a soft phosphor halo) rather than a filled block, matching the look
|
||||
// of a real XY vector-monitor arcade cabinet.
|
||||
drawSilo(g, base) {
|
||||
const { x, y } = base;
|
||||
if (base.alive) {
|
||||
g.fillStyle(this.C.accent, 1);
|
||||
g.fillTriangle(x - 34, y, x + 34, y, x, y - 60);
|
||||
g.fillStyle(0x0c0f16, 1);
|
||||
g.fillRect(x - 10, y - 22, 20, 22);
|
||||
const top = y - 62;
|
||||
g.lineStyle(6, this.C.accent, 0.18);
|
||||
g.strokeTriangle(x - 34, y, x + 34, y, x, top);
|
||||
g.lineStyle(2, this.C.accent, 1);
|
||||
g.strokeTriangle(x - 34, y, x + 34, y, x, top);
|
||||
|
||||
g.lineStyle(1.5, this.C.accent, 0.6);
|
||||
g.lineBetween(x - 18, y, x - 6, top + 14);
|
||||
g.lineBetween(x + 18, y, x + 6, top + 14);
|
||||
|
||||
g.lineStyle(1.5, 0xffffff, 0.7);
|
||||
g.strokeRect(x - 10, y - 22, 20, 22);
|
||||
|
||||
g.lineStyle(1.5, this.C.accent, 0.9);
|
||||
g.lineBetween(x, top, x, top - 16);
|
||||
g.strokeCircle(x, top - 20, 4);
|
||||
|
||||
for (let i = 0; i < base.ammo; i += 1) {
|
||||
g.fillStyle(0xffffff, 0.85);
|
||||
g.fillCircle(x - 24 + (i % 5) * 12, y - 74 - Math.floor(i / 5) * 12, 3);
|
||||
const col = i % 5;
|
||||
const row = Math.floor(i / 5);
|
||||
const px = x - 26 + col * 13;
|
||||
const py = y - 8 - row * 10;
|
||||
g.lineStyle(1.5, 0xffffff, 0.9);
|
||||
g.strokeRect(px - 3, py - 3, 6, 6);
|
||||
}
|
||||
} else {
|
||||
g.fillStyle(0x2a2a2a, 1);
|
||||
g.fillTriangle(x - 34, y, x + 34, y, x, y - 18);
|
||||
g.lineStyle(3, 0x000000, 0.6);
|
||||
g.lineBetween(x - 18, y - 22, x + 18, y - 2);
|
||||
g.lineBetween(x + 18, y - 22, x - 18, y - 2);
|
||||
g.lineStyle(2, 0x55524a, 0.85);
|
||||
g.strokeCircle(x, y - 6, 18);
|
||||
g.lineStyle(1.5, 0x3a3830, 0.8);
|
||||
g.lineBetween(x - 14, y - 18, x + 12, y + 4);
|
||||
g.lineBetween(x + 14, y - 18, x - 12, y + 4);
|
||||
}
|
||||
}
|
||||
|
||||
drawCity(g, city) {
|
||||
const { x, y } = city;
|
||||
if (city.alive) {
|
||||
const blocks = [{ w: 20, h: 36 }, { w: 16, h: 56 }, { w: 24, h: 28 }, { w: 18, h: 46 }];
|
||||
let cx = x - 44;
|
||||
for (const b of blocks) {
|
||||
g.fillStyle(this.C.trail, 0.9);
|
||||
g.fillRect(cx, y - b.h, b.w, b.h);
|
||||
const preset = BUILDING_PRESETS[city.slot % BUILDING_PRESETS.length];
|
||||
let tallestIdx = 0; let tallestH = 0;
|
||||
preset.forEach((b, i) => { if (b.h > tallestH) { tallestH = b.h; tallestIdx = i; } });
|
||||
|
||||
let cx = x - 46;
|
||||
preset.forEach((b, i) => {
|
||||
const bx = cx; const by = y - b.h;
|
||||
|
||||
g.lineStyle(5, this.C.trail, 0.16);
|
||||
g.strokeRect(bx, by, b.w, b.h);
|
||||
g.lineStyle(2, this.C.trail, 1);
|
||||
g.strokeRect(bx, by, b.w, b.h);
|
||||
|
||||
g.lineStyle(1, this.C.trail, 0.4);
|
||||
const floors = Math.max(1, Math.floor(b.h / 14));
|
||||
for (let f = 1; f < floors; f += 1) {
|
||||
const fy = by + (b.h / floors) * f;
|
||||
g.lineBetween(bx + 2, fy, bx + b.w - 2, fy);
|
||||
}
|
||||
|
||||
g.fillStyle(this.C.explosion, 0.5);
|
||||
g.fillRect(bx + 4, by + 6, 3, 3);
|
||||
if (b.w > 18) g.fillRect(bx + b.w - 8, by + b.h - 10, 3, 3);
|
||||
|
||||
if (i === tallestIdx) {
|
||||
g.lineStyle(1.5, this.C.trail, 0.9);
|
||||
g.lineBetween(bx + b.w / 2, by, bx + b.w / 2, by - 14);
|
||||
g.fillStyle(this.C.trail, 1);
|
||||
g.fillCircle(bx + b.w / 2, by - 14, 2);
|
||||
}
|
||||
|
||||
cx += b.w + 6;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
g.fillStyle(0x1a1a1a, 1);
|
||||
g.fillRect(x - 40, y - 8, 80, 8);
|
||||
g.fillStyle(0x0a0a0a, 1);
|
||||
g.fillCircle(x - 20, y - 4, 6);
|
||||
g.fillCircle(x + 15, y - 3, 5);
|
||||
const baseY = y - 4;
|
||||
g.lineStyle(2, 0x55524a, 0.85);
|
||||
g.beginPath();
|
||||
g.moveTo(x - 42, baseY);
|
||||
g.lineTo(x - 30, baseY - 10);
|
||||
g.lineTo(x - 16, baseY - 4);
|
||||
g.lineTo(x - 2, baseY - 14);
|
||||
g.lineTo(x + 12, baseY - 3);
|
||||
g.lineTo(x + 26, baseY - 11);
|
||||
g.lineTo(x + 40, baseY);
|
||||
g.strokePath();
|
||||
|
||||
g.lineStyle(1.5, 0x3a3830, 0.7);
|
||||
g.strokeTriangle(x - 20, baseY, x - 10, baseY - 16, x, baseY);
|
||||
g.strokeTriangle(x + 6, baseY, x + 16, baseY - 12, x + 26, baseY);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,14 +286,10 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
|
|||
this.drawGround();
|
||||
break;
|
||||
case 'missileDestroyed':
|
||||
playSound(this, SFX.LASER_ZAP);
|
||||
playSound(this, e.chained ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE);
|
||||
break;
|
||||
case 'waveComplete':
|
||||
this.C = PALETTES[e.paletteIdx];
|
||||
this.crt.setIntensity({ accentTint: this.C.accent, scanlineTint: this.C.trail });
|
||||
this.bgRect.setFillStyle(this.C.bgTop);
|
||||
this.drawGround();
|
||||
playSound(this, SFX.SCIFI_REVEAL);
|
||||
this.playWaveRecap(e);
|
||||
break;
|
||||
case 'gameOver':
|
||||
this.onGameOver(e);
|
||||
|
|
@ -214,8 +319,9 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
|
|||
for (const e of this.state.explosions) {
|
||||
const r = explosionRadius(e);
|
||||
if (r <= 0) continue;
|
||||
const color = e.owner === 'player' ? this.C.explosion : 0xff4433;
|
||||
g.fillStyle(color, 0.55);
|
||||
const color = e.owner === 'player' ? this.C.explosion : (e.owner === 'chain' ? this.C.chain : 0xff4433);
|
||||
const alpha = e.owner === 'chain' ? 0.62 : 0.55;
|
||||
g.fillStyle(color, alpha);
|
||||
g.fillCircle(e.x, e.y, r);
|
||||
g.lineStyle(2, 0xffffff, 0.8);
|
||||
g.strokeCircle(e.x, e.y, r);
|
||||
|
|
@ -223,8 +329,157 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
|
|||
this.updateHud();
|
||||
}
|
||||
|
||||
// ── Inter-wave recap ──────────────────────────────────────────────────────
|
||||
wait(ms) {
|
||||
return new Promise((resolve) => this.time.delayedCall(ms, resolve));
|
||||
}
|
||||
|
||||
rowPositions(count, centerX, spacing) {
|
||||
const totalW = (count - 1) * spacing;
|
||||
const startX = centerX - totalW / 2;
|
||||
return Array.from({ length: count }, (_, i) => startX + i * spacing);
|
||||
}
|
||||
|
||||
makeCityIcon(x, y) {
|
||||
const g = this.add.graphics().setPosition(x, y).setDepth(D.overlay + 1);
|
||||
const bars = [{ dx: -12, w: 8, h: 16 }, { dx: -2, w: 8, h: 24 }, { dx: 10, w: 8, h: 12 }];
|
||||
for (const b of bars) {
|
||||
g.lineStyle(4, this.C.trail, 0.18);
|
||||
g.strokeRect(b.dx, -b.h, b.w, b.h);
|
||||
g.lineStyle(1.5, this.C.trail, 1);
|
||||
g.strokeRect(b.dx, -b.h, b.w, b.h);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
makeMissileIcon(x, y) {
|
||||
const g = this.add.graphics().setPosition(x, y).setDepth(D.overlay + 1);
|
||||
g.lineStyle(5, this.C.accent, 0.18);
|
||||
g.strokeTriangle(-6, 14, 6, 14, 0, -14);
|
||||
g.lineStyle(1.5, this.C.accent, 1);
|
||||
g.strokeTriangle(-6, 14, 6, 14, 0, -14);
|
||||
return g;
|
||||
}
|
||||
|
||||
animateCityIntoRow(city, destX, destY, root) {
|
||||
const icon = this.makeCityIcon(city.x, city.y);
|
||||
root.add(icon);
|
||||
return new Promise((resolve) => {
|
||||
this.tweens.add({
|
||||
targets: icon, x: destX, y: destY, duration: 350, ease: 'Cubic.easeOut',
|
||||
onComplete: () => {
|
||||
const label = this.add.text(destX, destY + 22, '+500', {
|
||||
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlay + 1);
|
||||
root.add(label);
|
||||
this._recapScore += 500;
|
||||
this.scoreText.setText(`Score: ${this._recapScore}`);
|
||||
playSound(this, SFX.EIGHTBIT_COUNT);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
animateMissileIntoRow(base, destX, destY, root) {
|
||||
const icon = this.makeMissileIcon(base.x, base.y);
|
||||
root.add(icon);
|
||||
playSound(this, SFX.EIGHTBIT_CARD);
|
||||
return new Promise((resolve) => {
|
||||
this.tweens.add({
|
||||
targets: icon, x: destX, y: destY, duration: 220, ease: 'Cubic.easeOut', onComplete: resolve,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Runs the between-waves "COLORADO DEFENDED" bonus recap: title, then each
|
||||
// surviving city tallies +500 into a row, then each base's leftover ammo
|
||||
// tallies into a row for a lump +25/missile summary, then everything
|
||||
// resets for the next wave. Logic already applied the full bonus
|
||||
// atomically in completeWave() — this only replays it visually.
|
||||
async playWaveRecap(e) {
|
||||
this.overlayUp = true;
|
||||
this._recapActive = true;
|
||||
this._recapScore = this.state.score - e.cityBonus - e.ammoBonus;
|
||||
|
||||
this.C = PALETTES[e.paletteIdx];
|
||||
this.crt.setIntensity({ accentTint: this.C.accent, scanlineTint: this.C.trail });
|
||||
this.bgRect.setFillStyle(this.C.bgTop);
|
||||
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const bannerY = GAME_HEIGHT / 3;
|
||||
const cityRowY = bannerY + 130;
|
||||
const missileRowY = cityRowY + 120;
|
||||
const summaryY = missileRowY + 70;
|
||||
|
||||
const root = this.add.container(0, 0).setDepth(D.overlay);
|
||||
root.add(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55));
|
||||
|
||||
const bannerG = this.add.graphics().setDepth(D.overlay + 1);
|
||||
drawVectorText(bannerG, 'COLORADO DEFENDED', cx, bannerY, 13, this.C.accent);
|
||||
root.add(bannerG);
|
||||
|
||||
await this.wait(1000);
|
||||
|
||||
const aliveCities = this.state.cities.filter((c) => c.alive);
|
||||
if (aliveCities.length) {
|
||||
const cityX = this.rowPositions(aliveCities.length, cx, 110);
|
||||
let lastTween = null;
|
||||
for (let i = 0; i < aliveCities.length; i += 1) {
|
||||
lastTween = this.animateCityIntoRow(aliveCities[i], cityX[i], cityRowY, root);
|
||||
if (i < aliveCities.length - 1) await this.wait(400);
|
||||
}
|
||||
await lastTween;
|
||||
}
|
||||
|
||||
await this.wait(200);
|
||||
|
||||
if (e.totalAmmo > 0) {
|
||||
const missileX = this.rowPositions(e.totalAmmo, cx, 34);
|
||||
let mi = 0;
|
||||
let lastTween = null;
|
||||
for (let bi = 0; bi < this.state.bases.length; bi += 1) {
|
||||
const count = e.preAmmo[bi] ?? 0;
|
||||
for (let k = 0; k < count; k += 1) {
|
||||
lastTween = this.animateMissileIntoRow(this.state.bases[bi], missileX[mi], missileRowY, root);
|
||||
mi += 1;
|
||||
if (mi < e.totalAmmo) await this.wait(100);
|
||||
}
|
||||
}
|
||||
await lastTween;
|
||||
|
||||
const bigText = this.add.text(cx, summaryY, `+${e.ammoBonus}`, {
|
||||
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '44px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlay + 1);
|
||||
const capText = this.add.text(cx, summaryY + 34, `${e.totalAmmo} MISSILES REMAINING`, {
|
||||
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlay + 1);
|
||||
root.add([bigText, capText]);
|
||||
this._recapScore = this.state.score;
|
||||
this.scoreText.setText(`Score: ${this._recapScore}`);
|
||||
playSound(this, SFX.EIGHTBIT_ACTION);
|
||||
}
|
||||
|
||||
await this.wait(1500);
|
||||
|
||||
root.destroy(true);
|
||||
this.drawGround();
|
||||
this._recapActive = false;
|
||||
this.overlayUp = false;
|
||||
|
||||
if (this._pendingGameOver) {
|
||||
const pending = this._pendingGameOver;
|
||||
this._pendingGameOver = null;
|
||||
this.onGameOver(pending);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Game over ─────────────────────────────────────────────────────────────
|
||||
onGameOver(e) {
|
||||
// All cities can die in the same tick a wave completes; let the recap
|
||||
// finish (it already reflects the correct pre-game-over bonus) before
|
||||
// showing the game-over panel on top of it.
|
||||
if (this._recapActive) { this._pendingGameOver = e; return; }
|
||||
this.overlayUp = true;
|
||||
this.crt.pulse(1.0, 400);
|
||||
playSound(this, SFX.SCIFI_EXPLODE);
|
||||
|
|
|
|||
|
|
@ -62,7 +62,12 @@ export const TUNE = {
|
|||
BLAST_R_BASE: 90, BLAST_GROW_MS: 350, BLAST_HOLD_MS: 120, BLAST_FADE_MS: 200,
|
||||
INTERCEPTOR_SPEED: 900,
|
||||
SCORE_PER_KILL: 25,
|
||||
SCORE_WAVE_BONUS: 100,
|
||||
CITY_BONUS: 500,
|
||||
// A missile a player explosion kills detonates itself shortly after, with
|
||||
// a slower-blooming, bigger blast that can chain into others.
|
||||
CHAIN_DELAY_MS: 250,
|
||||
CHAIN_EXTRA_GROW_MS: 750,
|
||||
CHAIN_R_MULT: 1.5,
|
||||
};
|
||||
|
||||
export function spawnInterval(wave) {
|
||||
|
|
@ -82,11 +87,11 @@ export function mirvChance(wave) {
|
|||
// Automatic, level-driven palette progression (distinct from user-facing
|
||||
// pickers like 2048's) — advances every PALETTE_WAVES waves, clamped at the end.
|
||||
export const PALETTES = [
|
||||
{ bgTop: 0x0a0e1a, bgBottom: 0x1a1030, trail: 0x66ffea, explosion: 0xffdd55, accent: 0x00f0ff, ground: 0x141a2e },
|
||||
{ bgTop: 0x1a0a0e, bgBottom: 0x300a1a, trail: 0xff6bcb, explosion: 0xffaa33, accent: 0xff2bd6, ground: 0x2a0f1a },
|
||||
{ bgTop: 0x0a1a0e, bgBottom: 0x0a301a, trail: 0x8dff6b, explosion: 0xffee44, accent: 0x45d17a, ground: 0x0f2a18 },
|
||||
{ bgTop: 0x1a140a, bgBottom: 0x301c0a, trail: 0xffb366, explosion: 0xff5555, accent: 0xff8a3c, ground: 0x2a1c0f },
|
||||
{ bgTop: 0x120a1a, bgBottom: 0x260a30, trail: 0xc78dff, explosion: 0xff66c4, accent: 0xb84bff, ground: 0x1e0f2a },
|
||||
{ bgTop: 0x0a0e1a, bgBottom: 0x1a1030, trail: 0x66ffea, explosion: 0xffdd55, accent: 0x00f0ff, ground: 0x141a2e, chain: 0xff6688 },
|
||||
{ bgTop: 0x1a0a0e, bgBottom: 0x300a1a, trail: 0xff6bcb, explosion: 0xffaa33, accent: 0xff2bd6, ground: 0x2a0f1a, chain: 0xffee66 },
|
||||
{ bgTop: 0x0a1a0e, bgBottom: 0x0a301a, trail: 0x8dff6b, explosion: 0xffee44, accent: 0x45d17a, ground: 0x0f2a18, chain: 0xff7744 },
|
||||
{ bgTop: 0x1a140a, bgBottom: 0x301c0a, trail: 0xffb366, explosion: 0xff5555, accent: 0xff8a3c, ground: 0x2a1c0f, chain: 0xffee88 },
|
||||
{ bgTop: 0x120a1a, bgBottom: 0x260a30, trail: 0xc78dff, explosion: 0xff66c4, accent: 0xb84bff, ground: 0x1e0f2a, chain: 0x66ffcc },
|
||||
];
|
||||
export const PALETTE_WAVES = 4;
|
||||
export function paletteIndexForWave(wave) {
|
||||
|
|
@ -102,7 +107,9 @@ export function lerpPos(entity) {
|
|||
}
|
||||
|
||||
export function explosionRadius(e) {
|
||||
const { BLAST_GROW_MS: G, BLAST_HOLD_MS: H, BLAST_FADE_MS: F } = TUNE;
|
||||
const G = TUNE.BLAST_GROW_MS + (e.extraGrow || 0);
|
||||
const H = TUNE.BLAST_HOLD_MS;
|
||||
const F = TUNE.BLAST_FADE_MS;
|
||||
const t = e.t;
|
||||
if (t < G) return e.maxR * (t / G);
|
||||
if (t < G + H) return e.maxR;
|
||||
|
|
@ -131,6 +138,7 @@ export class Sim {
|
|||
this.enemyMissiles = [];
|
||||
this.interceptors = [];
|
||||
this.explosions = [];
|
||||
this.pendingChains = []; // missiles awaiting their delayed self-detonation
|
||||
|
||||
this.waveT = 0;
|
||||
this.waveSpawned = 0;
|
||||
|
|
@ -199,22 +207,39 @@ export class Sim {
|
|||
this.spawnExplosion(m.toX, m.toY, 'enemy');
|
||||
}
|
||||
|
||||
spawnExplosion(x, y, owner) {
|
||||
const totalMs = TUNE.BLAST_GROW_MS + TUNE.BLAST_HOLD_MS + TUNE.BLAST_FADE_MS;
|
||||
this.explosions.push({ id: this.nextId++, x, y, t: 0, totalMs, maxR: TUNE.BLAST_R_BASE, owner });
|
||||
this.emit('explosion', { x, y, owner });
|
||||
spawnExplosion(x, y, owner, opts = {}) {
|
||||
const extraGrow = opts.extraGrow || 0;
|
||||
const lethal = opts.lethal ?? (owner === 'player');
|
||||
const maxR = opts.maxR ?? TUNE.BLAST_R_BASE;
|
||||
const totalMs = TUNE.BLAST_GROW_MS + extraGrow + TUNE.BLAST_HOLD_MS + TUNE.BLAST_FADE_MS;
|
||||
this.explosions.push({
|
||||
id: this.nextId++, x, y, t: 0, totalMs, maxR, owner, lethal, extraGrow,
|
||||
});
|
||||
this.emit('explosion', { x, y, owner, lethal });
|
||||
}
|
||||
|
||||
completeWave() {
|
||||
// Snapshot pre-refill ammo and the survivor bonus breakdown so a
|
||||
// consumer (e.g. an inter-wave recap animation) can show exactly what
|
||||
// was scored, even though the transition below applies it atomically.
|
||||
const preAmmo = this.bases.map((b) => (b.alive ? b.ammo : 0));
|
||||
const aliveCityCount = this.aliveCities().length;
|
||||
const totalAmmo = preAmmo.reduce((a, b) => a + b, 0);
|
||||
const cityBonus = aliveCityCount * TUNE.CITY_BONUS;
|
||||
const ammoBonus = totalAmmo * TUNE.SCORE_PER_KILL;
|
||||
|
||||
this.wave += 1;
|
||||
for (const b of this.bases) if (b.alive) b.ammo = TUNE.BASE_AMMO;
|
||||
this.waveQuota = waveQuota(this.wave);
|
||||
this.waveSpawned = 0;
|
||||
this.spawnT = 0;
|
||||
this.waveT = 0;
|
||||
this.score += TUNE.SCORE_WAVE_BONUS;
|
||||
this.score += cityBonus + ammoBonus;
|
||||
this.paletteIdx = paletteIndexForWave(this.wave);
|
||||
this.emit('waveComplete', { wave: this.wave, paletteIdx: this.paletteIdx });
|
||||
this.emit('waveComplete', {
|
||||
wave: this.wave, paletteIdx: this.paletteIdx,
|
||||
preAmmo, aliveCityCount, cityBonus, totalAmmo, ammoBonus,
|
||||
});
|
||||
}
|
||||
|
||||
step(dtMs) {
|
||||
|
|
@ -260,13 +285,30 @@ export class Sim {
|
|||
}
|
||||
this.interceptors = stillInterceptors;
|
||||
|
||||
// Advance explosions and resolve collisions against enemy missiles.
|
||||
// Detonate any missile whose delayed self-destruct has come due, so its
|
||||
// secondary blast joins this tick's collision pass below.
|
||||
const readyChains = [];
|
||||
this.pendingChains = this.pendingChains.filter((c) => {
|
||||
c.delayMs -= dtMs;
|
||||
if (c.delayMs > 0) return true;
|
||||
readyChains.push(c);
|
||||
return false;
|
||||
});
|
||||
for (const c of readyChains) {
|
||||
this.spawnExplosion(c.x, c.y, 'chain', {
|
||||
lethal: true, extraGrow: TUNE.CHAIN_EXTRA_GROW_MS, maxR: TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT,
|
||||
});
|
||||
}
|
||||
|
||||
// Advance explosions and resolve collisions against enemy missiles. Only
|
||||
// a missile killed by the player's own blast (not a chain reaction) gets
|
||||
// a delayed secondary detonation — this bounds the chain to one extra tier.
|
||||
this.explosions = this.explosions.filter((e) => {
|
||||
e.t += dtMs;
|
||||
return e.t < e.totalMs;
|
||||
});
|
||||
for (const e of this.explosions) {
|
||||
if (e.owner !== 'player') continue;
|
||||
if (!e.lethal) continue;
|
||||
const r = explosionRadius(e);
|
||||
if (r <= 0) continue;
|
||||
this.enemyMissiles = this.enemyMissiles.filter((m) => {
|
||||
|
|
@ -275,7 +317,11 @@ export class Sim {
|
|||
if (d <= r) {
|
||||
const gained = TUNE.SCORE_PER_KILL * this.wave;
|
||||
this.score += gained;
|
||||
this.emit('missileDestroyed', { x: pos.x, y: pos.y, score: gained });
|
||||
const chained = e.owner === 'chain';
|
||||
this.emit('missileDestroyed', { x: pos.x, y: pos.y, score: gained, chained });
|
||||
if (!chained) {
|
||||
this.pendingChains.push({ x: pos.x, y: pos.y, delayMs: TUNE.CHAIN_DELAY_MS });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
// A minimal stroked vector font — letters as line segments in a 4x6 unit
|
||||
// cell, drawn as a glow-stroke pair (matching this game's silo/city look)
|
||||
// rather than a bitmap font. Covers only the letters this game currently
|
||||
// needs for its inter-wave banner; extend GLYPHS if more text is needed.
|
||||
|
||||
const GLYPH_W = 4;
|
||||
const GLYPH_H = 6;
|
||||
const GLYPH_GAP = 1.2;
|
||||
const SPACE_W = 2.6;
|
||||
|
||||
const GLYPHS = {
|
||||
A: [[[0, 6], [2, 0], [4, 6]], [[1, 3.4], [3, 3.4]]],
|
||||
C: [[[3.6, 1], [1.2, 0], [0, 1.6], [0, 4.4], [1.2, 6], [3.6, 5]]],
|
||||
D: [[[0, 0], [2.2, 0], [4, 1.6], [4, 4.4], [2.2, 6], [0, 6], [0, 0]]],
|
||||
E: [[[3.4, 0], [0, 0], [0, 6], [3.4, 6]], [[0, 3], [2.4, 3]]],
|
||||
F: [[[3.4, 0], [0, 0], [0, 6]], [[0, 3], [2.4, 3]]],
|
||||
L: [[[0, 0], [0, 6], [3.4, 6]]],
|
||||
N: [[[0, 6], [0, 0], [4, 6], [4, 0]]],
|
||||
O: [[[1.2, 0], [2.8, 0], [4, 1.6], [4, 4.4], [2.8, 6], [1.2, 6], [0, 4.4], [0, 1.6], [1.2, 0]]],
|
||||
R: [[[0, 6], [0, 0], [3, 0], [4, 1.2], [3, 2.4], [0, 2.4]], [[1.6, 2.4], [4, 6]]],
|
||||
};
|
||||
|
||||
function strokePoly(g, pts) {
|
||||
g.beginPath();
|
||||
g.moveTo(pts[0][0], pts[0][1]);
|
||||
for (let i = 1; i < pts.length; i += 1) g.lineTo(pts[i][0], pts[i][1]);
|
||||
g.strokePath();
|
||||
}
|
||||
|
||||
export function measureVectorText(text, scale) {
|
||||
let w = 0;
|
||||
for (const ch of text.toUpperCase()) {
|
||||
w += (ch === ' ' ? SPACE_W : GLYPH_W + GLYPH_GAP) * scale;
|
||||
}
|
||||
return w - GLYPH_GAP * scale;
|
||||
}
|
||||
|
||||
export function drawVectorText(g, text, cx, cy, scale, color, options = {}) {
|
||||
const { lineWidth = 3, glowWidth = 9, glowAlpha = 0.18, alpha = 1 } = options;
|
||||
const totalW = measureVectorText(text, scale);
|
||||
let x = cx - totalW / 2;
|
||||
const y = cy - (GLYPH_H * scale) / 2;
|
||||
for (const ch of text.toUpperCase()) {
|
||||
if (ch === ' ') { x += SPACE_W * scale; continue; }
|
||||
const strokes = GLYPHS[ch];
|
||||
if (strokes) {
|
||||
for (const poly of strokes) {
|
||||
const pts = poly.map(([ux, uy]) => [x + ux * scale, y + uy * scale]);
|
||||
g.lineStyle(glowWidth, color, glowAlpha);
|
||||
strokePoly(g, pts);
|
||||
g.lineStyle(lineWidth, color, alpha);
|
||||
strokePoly(g, pts);
|
||||
}
|
||||
}
|
||||
x += (GLYPH_W + GLYPH_GAP) * scale;
|
||||
}
|
||||
}
|
||||
|
|
@ -113,6 +113,9 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.audio('sfx-8bit-activate', 'assets/fx/8bit-activate.mp3');
|
||||
this.load.audio('sfx-8bit-action', 'assets/fx/8bit-action.mp3');
|
||||
this.load.audio('sfx-8bit-move', 'assets/fx/8bit-move.mp3');
|
||||
this.load.audio('sfx-8bit-explode', 'assets/fx/8bit-explode.mp3');
|
||||
this.load.audio('sfx-8bit-explode2', 'assets/fx/8bit-explode2.mp3');
|
||||
this.load.audio('sfx-8bit-count', 'assets/fx/8bit-count.mp3');
|
||||
this.load.audio('sfx-squish', 'assets/fx/squish.mp3');
|
||||
this.load.audio('sfx-squash', 'assets/fx/squash.mp3');
|
||||
this.load.audio('sfx-woosh', 'assets/fx/woosh.mp3');
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../config.js';
|
||||
import { attachArcadeCurve } from './ArcadeCurvePipeline.js';
|
||||
|
||||
// Reusable "arcade cabinet" screen dressing: animated scanlines + a vector
|
||||
// TV-style bevel/vignette. Drop this onto any arcade/console game scene.
|
||||
// Reusable "arcade cabinet" screen dressing: animated scanlines (GameObject-
|
||||
// based, works on any renderer) plus a real barrel-distorted curved-CRT look
|
||||
// (WebGL post-FX pipeline, see ArcadeCurvePipeline.js — Canvas gets a no-op
|
||||
// stub for that half, scanlines still show). Drop this onto any
|
||||
// arcade/console game scene.
|
||||
//
|
||||
// (src/games/balatro/BalatroCrtPipeline.js is a different, WebGL-shader-based
|
||||
// CRT effect that's single-consumer and Canvas-incompatible — not reused here;
|
||||
// this module is GameObject-based so it works on any renderer.)
|
||||
// (src/games/balatro/BalatroCrtPipeline.js is a separate, single-consumer
|
||||
// WebGL CRT effect — not reused here, but this module follows the same
|
||||
// PostFXPipeline convention for its curvature half.)
|
||||
//
|
||||
// Usage:
|
||||
// this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xff6b3a });
|
||||
// this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xff6b3a, curveAmount: 0.4 });
|
||||
// this.events.once('shutdown', () => this.crt.destroy());
|
||||
// this.crt.pulse(1.0, 400); // one-off impact flash
|
||||
// this.crt.setIntensity({ accentTint: C.accent }); // re-skin on palette change
|
||||
|
|
@ -32,12 +36,13 @@ export function applyArcadeCRTOverlay(scene, options = {}) {
|
|||
scanlineAlpha: 0.65,
|
||||
scanlineTint: 0x00f0ff,
|
||||
scanlineSpeedMs: 9000,
|
||||
bevelThickness: 78,
|
||||
bevelColor: 0x05060a,
|
||||
bevelRadius: 32,
|
||||
vignetteStrength: 0.78,
|
||||
accentTint: 0xc8a84b,
|
||||
depth: { scan: 58, bevel: 59 },
|
||||
depth: { scan: 58 },
|
||||
curveAmount: 0.35,
|
||||
curveBezelColor: 0x05060a,
|
||||
curveVignetteStrength: 0.35,
|
||||
curveSheenStrength: 0.08,
|
||||
curveAberration: 0.0035,
|
||||
...options,
|
||||
};
|
||||
|
||||
|
|
@ -58,43 +63,13 @@ export function applyArcadeCRTOverlay(scene, options = {}) {
|
|||
ease: 'Linear',
|
||||
});
|
||||
|
||||
const bevel = scene.add.graphics().setDepth(opts.depth.bevel);
|
||||
|
||||
function drawVignette(g) {
|
||||
const corners = [
|
||||
[0, 0], [GAME_WIDTH, 0], [0, GAME_HEIGHT], [GAME_WIDTH, GAME_HEIGHT],
|
||||
];
|
||||
for (const [cx, cy] of corners) {
|
||||
g.fillStyle(0x000000, opts.vignetteStrength * 0.42);
|
||||
g.fillCircle(cx, cy, 480);
|
||||
g.fillStyle(0x000000, opts.vignetteStrength * 0.3);
|
||||
g.fillCircle(cx, cy, 320);
|
||||
}
|
||||
const rings = 7;
|
||||
for (let i = 0; i < rings; i += 1) {
|
||||
const t = i / (rings - 1);
|
||||
const inset = opts.bevelThickness + t * 180;
|
||||
const alpha = opts.vignetteStrength * (1 - t) * 0.16;
|
||||
g.lineStyle(52, 0x000000, alpha);
|
||||
g.strokeRoundedRect(inset, inset, GAME_WIDTH - inset * 2, GAME_HEIGHT - inset * 2, opts.bevelRadius + 8);
|
||||
}
|
||||
}
|
||||
|
||||
function drawBevel() {
|
||||
bevel.clear();
|
||||
drawVignette(bevel);
|
||||
const inset = opts.bevelThickness / 2;
|
||||
bevel.lineStyle(opts.bevelThickness, opts.bevelColor, 1);
|
||||
bevel.strokeRoundedRect(inset, inset, GAME_WIDTH - opts.bevelThickness, GAME_HEIGHT - opts.bevelThickness, opts.bevelRadius);
|
||||
bevel.lineStyle(9, opts.accentTint, 0.65);
|
||||
bevel.strokeRoundedRect(
|
||||
opts.bevelThickness, opts.bevelThickness,
|
||||
GAME_WIDTH - opts.bevelThickness * 2, GAME_HEIGHT - opts.bevelThickness * 2,
|
||||
Math.max(0, opts.bevelRadius - 8),
|
||||
);
|
||||
}
|
||||
|
||||
drawBevel();
|
||||
const curve = attachArcadeCurve(scene, {
|
||||
amount: opts.curveAmount,
|
||||
bezelColor: opts.curveBezelColor,
|
||||
vignetteStrength: opts.curveVignetteStrength,
|
||||
sheenStrength: opts.curveSheenStrength,
|
||||
aberration: opts.curveAberration,
|
||||
});
|
||||
|
||||
let flashTween = null;
|
||||
|
||||
|
|
@ -106,17 +81,24 @@ export function applyArcadeCRTOverlay(scene, options = {}) {
|
|||
flashTween = scene.tweens.add({
|
||||
targets: scan, alpha: opts.scanlineAlpha, duration: durationMs * 2,
|
||||
});
|
||||
curve.pulse(strength);
|
||||
},
|
||||
setIntensity(patch = {}) {
|
||||
Object.assign(opts, patch);
|
||||
scan.setAlpha(opts.scanlineAlpha).setTint(opts.scanlineTint);
|
||||
drawBevel();
|
||||
curve.setUniforms({
|
||||
amount: opts.curveAmount,
|
||||
bezelColor: opts.curveBezelColor,
|
||||
vignetteStrength: opts.curveVignetteStrength,
|
||||
sheenStrength: opts.curveSheenStrength,
|
||||
aberration: opts.curveAberration,
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
scanTween.stop();
|
||||
if (flashTween) flashTween.stop();
|
||||
scan.destroy();
|
||||
bevel.destroy();
|
||||
curve.destroy();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
// ArcadeCurvePipeline.js
|
||||
// A WebGL camera post-FX pipeline that simulates an 80s curved CRT monitor:
|
||||
// real barrel-distortion of the rendered scene (not just a painted vignette),
|
||||
// a dark bezel where the curved glass falls outside the screen rect, a soft
|
||||
// glass highlight, and a touch of edge chromatic aberration. Everything on
|
||||
// the camera — gameplay and HUD alike — gets warped together, same as a real
|
||||
// curved tube would show it.
|
||||
//
|
||||
// Modeled on src/games/balatro/BalatroCrtPipeline.js's PostFXPipeline
|
||||
// convention. Canvas renderer (no WebGL) gets a no-op stub.
|
||||
//
|
||||
// This is an internal helper for src/ui/ArcadeCRTOverlay.js — games should
|
||||
// go through applyArcadeCRTOverlay() rather than calling attachArcadeCurve()
|
||||
// directly, so scanlines + curvature stay one bundled effect.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
|
||||
const FRAG = `
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D uMainSampler;
|
||||
uniform vec2 uCurvature;
|
||||
uniform float uVignette;
|
||||
uniform vec3 uBezelColor;
|
||||
uniform float uSheen;
|
||||
uniform float uAberration;
|
||||
uniform float uPulse;
|
||||
|
||||
varying vec2 outTexCoord;
|
||||
|
||||
vec2 curveUV(vec2 uv) {
|
||||
uv = uv * 2.0 - 1.0;
|
||||
vec2 offset = abs(uv.yx) / uCurvature;
|
||||
uv = uv + uv * offset * offset;
|
||||
uv = uv * 0.5 + 0.5;
|
||||
return uv;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec2 uv = curveUV(outTexCoord);
|
||||
|
||||
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
|
||||
gl_FragColor = vec4(uBezelColor, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
float ca = uAberration * (0.4 + uPulse);
|
||||
vec3 col;
|
||||
col.r = texture2D(uMainSampler, clamp(uv + vec2(ca, 0.0), 0.0, 1.0)).r;
|
||||
col.g = texture2D(uMainSampler, uv).g;
|
||||
col.b = texture2D(uMainSampler, clamp(uv - vec2(ca, 0.0), 0.0, 1.0)).b;
|
||||
|
||||
// Extra darkening toward the curved edge, on top of the natural falloff
|
||||
// barrel distortion already gives foreshortened corner pixels.
|
||||
vec2 vc = uv - 0.5;
|
||||
float vig = 1.0 - uVignette * dot(vc, vc) * 2.2;
|
||||
col *= clamp(vig, 0.0, 1.0);
|
||||
|
||||
// Soft fixed highlight, like a light catching the top-left of convex glass.
|
||||
float sheen = smoothstep(0.6, 0.0, length(uv - vec2(0.30, 0.20))) * uSheen;
|
||||
col += sheen;
|
||||
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const DEFAULTS = {
|
||||
amount: 0.35, // 0 = nearly flat, 1 = strongly curved
|
||||
vignetteStrength: 0.35,
|
||||
bezelColor: 0x05060a,
|
||||
sheenStrength: 0.08,
|
||||
aberration: 0.0035,
|
||||
};
|
||||
|
||||
function hexToRgb01(hex) {
|
||||
return [((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255];
|
||||
}
|
||||
|
||||
export class ArcadeCurvePipeline extends Phaser.Renderer.WebGL.Pipelines.PostFXPipeline {
|
||||
constructor(game) {
|
||||
super({ game, name: 'ArcadeCurve', fragShader: FRAG });
|
||||
this.amount = DEFAULTS.amount;
|
||||
this.vignetteStrength = DEFAULTS.vignetteStrength;
|
||||
this.bezelRgb = hexToRgb01(DEFAULTS.bezelColor);
|
||||
this.sheenStrength = DEFAULTS.sheenStrength;
|
||||
this.aberration = DEFAULTS.aberration;
|
||||
this._pulse = 0;
|
||||
}
|
||||
|
||||
// Bigger amount -> smaller divisors -> more pronounced barrel bulge.
|
||||
curvatureVec() {
|
||||
return [10.0 - this.amount * 7.0, 8.0 - this.amount * 5.5];
|
||||
}
|
||||
|
||||
onPreRender() {
|
||||
const [cx, cy] = this.curvatureVec();
|
||||
this.set2f('uCurvature', cx, cy);
|
||||
this.set1f('uVignette', this.vignetteStrength);
|
||||
this.set3f('uBezelColor', this.bezelRgb[0], this.bezelRgb[1], this.bezelRgb[2]);
|
||||
this.set1f('uSheen', this.sheenStrength);
|
||||
this.set1f('uAberration', this.aberration);
|
||||
this.set1f('uPulse', this._pulse);
|
||||
this._pulse *= 0.88;
|
||||
if (this._pulse < 0.005) this._pulse = 0;
|
||||
}
|
||||
|
||||
// Event hook: momentarily intensify the edge aberration (0..1).
|
||||
pulse(strength = 0.5) {
|
||||
this._pulse = Math.min(1, Math.max(this._pulse, strength));
|
||||
}
|
||||
|
||||
setUniforms(patch = {}) {
|
||||
if (patch.amount != null) this.amount = patch.amount;
|
||||
if (patch.vignetteStrength != null) this.vignetteStrength = patch.vignetteStrength;
|
||||
if (patch.bezelColor != null) this.bezelRgb = hexToRgb01(patch.bezelColor);
|
||||
if (patch.sheenStrength != null) this.sheenStrength = patch.sheenStrength;
|
||||
if (patch.aberration != null) this.aberration = patch.aberration;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the curvature post-FX to the scene's main camera. Returns a control
|
||||
// object; on Canvas it is a stub so callers never need to branch on renderer.
|
||||
export function attachArcadeCurve(scene, options = {}) {
|
||||
if (!scene.renderer || scene.renderer.type !== Phaser.WEBGL) {
|
||||
return { setUniforms() {}, pulse() {}, destroy() {} };
|
||||
}
|
||||
scene.renderer.pipelines.addPostPipeline('ArcadeCurve', ArcadeCurvePipeline);
|
||||
const cam = scene.cameras.main;
|
||||
cam.setPostPipeline(ArcadeCurvePipeline);
|
||||
const got = cam.getPostPipeline(ArcadeCurvePipeline);
|
||||
const inst = Array.isArray(got) ? got[0] : got;
|
||||
if (inst) inst.setUniforms({ ...DEFAULTS, ...options });
|
||||
return {
|
||||
setUniforms(patch) { if (inst) inst.setUniforms(patch); },
|
||||
pulse(strength) { if (inst) inst.pulse(strength); },
|
||||
destroy() { cam.removePostPipeline(ArcadeCurvePipeline); },
|
||||
};
|
||||
}
|
||||
|
|
@ -44,6 +44,10 @@ export const SFX = {
|
|||
EIGHTBIT_ACTIVATE: 'sfx-8bit-activate',
|
||||
EIGHTBIT_ACTION: 'sfx-8bit-action',
|
||||
EIGHTBIT_MOVE: 'sfx-8bit-move',
|
||||
EIGHTBIT_CARD: 'sfx-card-deal-8bit',
|
||||
EIGHTBIT_EXPLODE: 'sfx-8bit-explode',
|
||||
EIGHTBIT_EXPLODE_2: 'sfx-8bit-explode2',
|
||||
EIGHTBIT_COUNT: 'sfx-8bit-count',
|
||||
SQUISH: 'sfx-squish',
|
||||
SQUASH: 'sfx-squash',
|
||||
WOOSH: 'sfx-woosh',
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
// 4. MIRV step-unlock threshold.
|
||||
// 5. Scripted explosion/collision fixture.
|
||||
// 6. Palette-progression monotonicity.
|
||||
// 7. Monte-carlo "always fire at nearest incoming missile" bot run.
|
||||
// 7. Wave-complete bonus breakdown (recap data) matches the score delta.
|
||||
// 8. Monte-carlo "always fire at nearest incoming missile" bot run.
|
||||
|
||||
import {
|
||||
BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, slotX, mulberry32, pickCities,
|
||||
|
|
@ -134,6 +135,77 @@ console.log('Explosion/collision fixture');
|
|||
check('missile far outside blast radius survives', sim2.enemyMissiles.length === 1);
|
||||
}
|
||||
|
||||
// ── 5b. Chain-reaction fixture ─────────────────────────────────────────────────
|
||||
|
||||
console.log('Chain-reaction fixture');
|
||||
{
|
||||
const durSim = createGame(DEFAULT_CITIES, 1);
|
||||
durSim.spawnExplosion(0, 0, 'player');
|
||||
const normalTotal = durSim.explosions[0].totalMs;
|
||||
durSim.spawnExplosion(0, 0, 'chain', {
|
||||
lethal: true, extraGrow: TUNE.CHAIN_EXTRA_GROW_MS, maxR: TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT,
|
||||
});
|
||||
const chainExp = durSim.explosions[1];
|
||||
check('chain explosion lasts exactly CHAIN_EXTRA_GROW_MS longer than a normal one',
|
||||
chainExp.totalMs - normalTotal === TUNE.CHAIN_EXTRA_GROW_MS, `normal=${normalTotal} chain=${chainExp.totalMs}`);
|
||||
check('the extra time is spent growing to full size, not lingering at it',
|
||||
explosionRadius({ t: TUNE.BLAST_GROW_MS + 1, maxR: 90, extraGrow: TUNE.CHAIN_EXTRA_GROW_MS }) < 90,
|
||||
'radius already maxed before the slower grow phase finished');
|
||||
check('chain explosion max radius is CHAIN_R_MULT times a normal one',
|
||||
chainExp.maxR === TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT,
|
||||
`expected ${TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT} got ${chainExp.maxR}`);
|
||||
|
||||
const sim = createGame(DEFAULT_CITIES, 21);
|
||||
const target = sim.cities[0];
|
||||
sim.enemyMissiles.push({
|
||||
id: sim.nextId++, fromX: target.x, fromY: 0, toX: target.x, toY: target.y,
|
||||
targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true,
|
||||
});
|
||||
const primaryPos = lerpPos(sim.enemyMissiles[0]);
|
||||
const base = pickFiringBase(sim, primaryPos.x, primaryPos.y);
|
||||
fireInterceptor(sim, base, primaryPos.x, primaryPos.y);
|
||||
// Note: the interceptor itself takes real flight time to arrive (the
|
||||
// original blast doesn't start at t=0), so a fixed-schedule secondary
|
||||
// missile can't be pre-timed reliably. Instead, inject a near-stationary
|
||||
// missile inside the chain's radius only once the chain blast is confirmed
|
||||
// under way (well after the original — which shares the same radius —
|
||||
// would have already faded), so the chain is unambiguously the killer.
|
||||
|
||||
let primaryKillElapsed = null;
|
||||
let chainExplosionElapsed = null;
|
||||
let secondaryInjected = false;
|
||||
let secondaryKilledByChain = false;
|
||||
let elapsed = 0;
|
||||
for (let steps = 0; steps < 300; steps += 1) {
|
||||
const events = step(sim, 16);
|
||||
elapsed += 16;
|
||||
for (const ev of events) {
|
||||
if (ev.type === 'missileDestroyed' && !ev.chained && primaryKillElapsed === null) primaryKillElapsed = elapsed;
|
||||
if (ev.type === 'explosion' && ev.owner === 'chain' && chainExplosionElapsed === null) chainExplosionElapsed = elapsed;
|
||||
if (ev.type === 'missileDestroyed' && ev.chained) secondaryKilledByChain = true;
|
||||
}
|
||||
// The original blast is only guaranteed faded 670ms after IT spawns
|
||||
// (~primaryKillElapsed). The chain (spawned ~250ms after the kill) grows
|
||||
// to a 135px max over 1100ms, passing the 50px test distance ~407ms into
|
||||
// its own life (250+407=657ms after the kill) — wait past both before
|
||||
// injecting, well inside its 1670ms total lethal window.
|
||||
if (!secondaryInjected && primaryKillElapsed !== null && elapsed >= primaryKillElapsed + 1000) {
|
||||
sim.enemyMissiles.push({
|
||||
id: sim.nextId++, fromX: primaryPos.x - 50, fromY: primaryPos.y, toX: primaryPos.x - 50, toY: primaryPos.y,
|
||||
targetSlot: sim.cities[1].slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true,
|
||||
});
|
||||
secondaryInjected = true;
|
||||
}
|
||||
}
|
||||
check('primary missile destroyed by direct hit', primaryKillElapsed !== null);
|
||||
check('a chain explosion follows ~CHAIN_DELAY_MS later',
|
||||
chainExplosionElapsed !== null
|
||||
&& Math.abs((chainExplosionElapsed - primaryKillElapsed) - TUNE.CHAIN_DELAY_MS) <= 16,
|
||||
`primary=${primaryKillElapsed} chain=${chainExplosionElapsed}`);
|
||||
check('missile dropped inside the chain blast is destroyed by it', secondaryKilledByChain);
|
||||
check('chain reaction bounded to one extra tier (no dangling pending chains)', sim.pendingChains.length === 0);
|
||||
}
|
||||
|
||||
// ── 6. Palette progression ────────────────────────────────────────────────────
|
||||
|
||||
console.log('Palette progression');
|
||||
|
|
@ -146,7 +218,37 @@ console.log('Palette progression');
|
|||
check('palette index clamps at array end', paletteIndexForWave(9999) === PALETTES.length - 1);
|
||||
}
|
||||
|
||||
// ── 7. Monte-carlo bot run ────────────────────────────────────────────────────
|
||||
// ── 7. Wave-complete bonus breakdown ──────────────────────────────────────────
|
||||
|
||||
console.log('Wave-complete bonus breakdown');
|
||||
{
|
||||
const sim = createGame(DEFAULT_CITIES, 99);
|
||||
sim.cities[0].alive = false; // 5 cities survive
|
||||
sim.bases[0].ammo = 4;
|
||||
sim.bases[1].ammo = 0;
|
||||
sim.bases[2].ammo = 7;
|
||||
const scoreBefore = sim.score;
|
||||
|
||||
const events = [];
|
||||
const origEmit = sim.emit.bind(sim);
|
||||
sim.emit = (type, data) => { events.push({ type, ...data }); origEmit(type, data); };
|
||||
sim.completeWave();
|
||||
|
||||
const e = events.find((ev) => ev.type === 'waveComplete');
|
||||
check('waveComplete event emitted', !!e);
|
||||
check('aliveCityCount matches surviving cities', e.aliveCityCount === 5, `got ${e.aliveCityCount}`);
|
||||
check('cityBonus = aliveCityCount * CITY_BONUS', e.cityBonus === 5 * TUNE.CITY_BONUS);
|
||||
check('preAmmo snapshot matches pre-refill ammo', e.preAmmo.join(',') === '4,0,7', e.preAmmo.join(','));
|
||||
check('totalAmmo sums preAmmo', e.totalAmmo === 11, `got ${e.totalAmmo}`);
|
||||
check('ammoBonus = totalAmmo * SCORE_PER_KILL', e.ammoBonus === 11 * TUNE.SCORE_PER_KILL);
|
||||
check('score increased by exactly cityBonus + ammoBonus',
|
||||
sim.score === scoreBefore + e.cityBonus + e.ammoBonus,
|
||||
`before=${scoreBefore} after=${sim.score}`);
|
||||
check('surviving bases refilled to full ammo after transition',
|
||||
sim.bases[0].ammo === TUNE.BASE_AMMO && sim.bases[2].ammo === TUNE.BASE_AMMO);
|
||||
}
|
||||
|
||||
// ── 8. Monte-carlo bot run ────────────────────────────────────────────────────
|
||||
|
||||
console.log('Monte-carlo bot run');
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue