attention-retro-disorder/src/games/burli-kong/BurliKong.js

590 lines
22 KiB
JavaScript

import { playMusic, stopMusic } from '../../audio.js';
const PLATFORMS = [
{ y: 820, xMin: 50, xMax: 1550 }, // 0 = floor
{ y: 660, xMin: 50, xMax: 1550 }, // 1
{ y: 495, xMin: 50, xMax: 1550 }, // 2
{ y: 330, xMin: 50, xMax: 1550 }, // 3
{ y: 165, xMin: 50, xMax: 1550 }, // 4 = Kong platform (WIN)
];
// Ladders: bottom connects lower platform, top connects upper platform
// Zone: player can grab if within ±20px horizontally of center
const LADDERS = [
{ x: 1150, fromPlatform: 0, yBottom: 820, yTop: 660 }, // Floor → P1
{ x: 380, fromPlatform: 1, yBottom: 660, yTop: 495 }, // P1 → P2
{ x: 1050, fromPlatform: 2, yBottom: 495, yTop: 330 }, // P2 → P3
{ x: 680, fromPlatform: 3, yBottom: 330, yTop: 165 }, // P3 → Kong (WIN lane)
];
const PLATFORM_H = 15;
const PLAYER_W = 28;
const PLAYER_H = 40;
const BARREL_R = 14;
const LADDER_W = 36;
const LADDER_GRAB_RANGE = 28;
// Initial direction for barrels per platform index
const BARREL_DIR = [1, -1, 1, -1, 1]; // index = platformIndex
export default class BurliKong extends Phaser.Scene {
constructor() {
super({ key: 'BurliKong' });
}
preload() {
this.load.json('bkConfig', 'src/games/burli-kong/config.json');
this.load.spritesheet('burliSprite', 'assets/images/burli-sprite.png', { frameWidth: 135, frameHeight: 135 });
this.load.audio('bkMusic', 'assets/music/06-burli-kong.mp3');
this.load.audio('bkComplete', 'assets/fx/complete.mp3');
this.load.audio('bkJump', 'assets/fx/launch.mp3');
this.load.audio('bkStomp', 'assets/fx/destroy.mp3');
this.load.audio('bkHit', 'assets/fx/short_death.mp3');
this.load.audio('bkBarrel', 'assets/fx/fireball.mp3');
}
init(data) {
this.lives = data.lives ?? 3;
this.level = String(data.level ?? 1);
}
create() {
const all = this.cache.json.get('bkConfig');
this.cfg = all.levels[this.level] ?? all.levels['1'];
this.gameActive = true;
this.barrelsCleared = 0;
this.spawnTimer = 0;
this.barrels = [];
this.kongArmRaise = 0;
// Idle animation state
this.kongIdleTimer = 0;
this.kongIdleInterval = 3.5;
this.kongIsIdling = false;
this.kongIdleSeq = 0; // incremented to invalidate stale delayed callbacks
this.player = {
x: 200,
y: PLATFORMS[0].y - PLAYER_H,
vx: 0,
vy: 0,
state: 'ground', // 'ground' | 'jumping' | 'ladder'
platformIndex: 0,
facingDir: 1,
ladderIndex: -1,
jumpedOverBarrels: new Set(),
};
// Background drawn once at depth 0
const bgGfx = this.add.graphics().setDepth(0);
this._drawBackground(bgGfx);
// Kong sprite at depth 1, bottom-anchored to platform surface
this.kongSprite = this.add.sprite(95, PLATFORMS[4].y - PLATFORM_H, 'burliSprite', 0)
.setOrigin(0.5, 1)
.setDepth(1);
// Sprite 5: static, 20px to the right of Kong
this.add.image(250, PLATFORMS[4].y - PLATFORM_H, 'burliSprite', 5)
.setOrigin(0.5, 1)
.setDepth(1);
// Game elements (platforms, ladders, barrels, player) at depth 2
this.mainGfx = this.add.graphics().setDepth(2);
this.cursors = this.input.keyboard.createCursorKeys();
this.keyA = this.input.keyboard.addKey('A');
this.keyAPressed = false;
this._buildHUD();
playMusic(this, 'bkMusic');
}
update(time, delta) {
if (!this.gameActive) return;
const dt = delta / 1000;
this._handleInput(dt);
this._spawnBarrels(dt);
this._updateBarrels(dt);
this._checkCollisions();
this._updateKongSprite(dt);
this._draw();
this._updateHUD();
}
// ─── Input ────────────────────────────────────────────────────────────────
_handleInput(dt) {
const p = this.player;
const cfg = this.cfg;
// Jump (A key, edge-triggered, ground only)
if (this.keyA.isDown && !this.keyAPressed) {
this.keyAPressed = true;
if (p.state === 'ground') {
p.state = 'jumping';
p.vy = -cfg.jumpVelocity;
this._playFx('bkJump', 0.5);
}
}
if (!this.keyA.isDown) this.keyAPressed = false;
if (p.state === 'ground') {
p.vx = 0;
if (this.cursors.left.isDown) { p.vx = -cfg.playerSpeed; p.facingDir = -1; }
if (this.cursors.right.isDown) { p.vx = cfg.playerSpeed; p.facingDir = 1; }
p.x += p.vx * dt;
p.x = Phaser.Math.Clamp(p.x, PLATFORMS[p.platformIndex].xMin + PLAYER_W / 2,
PLATFORMS[p.platformIndex].xMax - PLAYER_W / 2);
// Snap y to current platform surface
p.y = PLATFORMS[p.platformIndex].y - PLAYER_H;
// Start climbing: up arrow near ladder bottom on current platform
if (this.cursors.up.isDown) {
const li = this._nearLadderBottom(p);
if (li >= 0) {
p.state = 'ladder';
p.ladderIndex = li;
p.x = LADDERS[li].x;
p.vx = 0;
}
}
} else if (p.state === 'jumping') {
p.vy += cfg.gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.x = Phaser.Math.Clamp(p.x, 55, 1545);
// Land on current platform (only if moving downward and crossing platform top)
const plat = PLATFORMS[p.platformIndex];
const platTop = plat.y - PLATFORM_H;
if (p.vy > 0 && p.y + PLAYER_H >= platTop) {
p.y = plat.y - PLAYER_H;
p.vy = 0;
p.state = 'ground';
}
} else if (p.state === 'ladder') {
const ladder = LADDERS[p.ladderIndex];
if (this.cursors.up.isDown) {
p.y -= cfg.climbSpeed * dt;
} else if (this.cursors.down.isDown) {
p.y += cfg.climbSpeed * dt;
}
// Dismount at top: reached upper platform
if (p.y <= ladder.yTop - PLAYER_H) {
const newPlatformIndex = ladder.fromPlatform + 1;
p.y = PLATFORMS[newPlatformIndex].y - PLAYER_H;
p.platformIndex = newPlatformIndex;
p.state = 'ground';
p.ladderIndex = -1;
// Win: reached Kong platform
if (p.platformIndex === 4) {
this._endGame(true, 'MADE IT!');
return;
}
}
// Dismount at bottom: dropped below ladder bottom
if (p.y + PLAYER_H >= ladder.yBottom) {
p.y = PLATFORMS[ladder.fromPlatform].y - PLAYER_H;
p.platformIndex = ladder.fromPlatform;
p.state = 'ground';
p.ladderIndex = -1;
}
// Dismount sideways: left/right while on ladder (onto current platform level)
if (this.cursors.left.isDown || this.cursors.right.isDown) {
// Find which platform the player's midpoint is closest to
const midY = p.y + PLAYER_H / 2;
let bestPlat = ladder.fromPlatform;
let bestDist = Infinity;
for (let i = ladder.fromPlatform; i <= ladder.fromPlatform + 1; i++) {
const d = Math.abs(PLATFORMS[i].y - PLATFORM_H / 2 - midY);
if (d < bestDist) { bestDist = d; bestPlat = i; }
}
p.platformIndex = bestPlat;
p.y = PLATFORMS[bestPlat].y - PLAYER_H;
p.state = 'ground';
p.facingDir = this.cursors.left.isDown ? -1 : 1;
p.ladderIndex = -1;
}
}
}
_nearLadderBottom(p) {
for (let i = 0; i < LADDERS.length; i++) {
const l = LADDERS[i];
if (l.fromPlatform !== p.platformIndex) continue;
if (Math.abs(p.x - l.x) <= LADDER_GRAB_RANGE) return i;
}
return -1;
}
// ─── Spawn ────────────────────────────────────────────────────────────────
_spawnBarrels(dt) {
this.spawnTimer += dt;
if (this.spawnTimer < this.cfg.barrelSpawnInterval) return;
if (this.barrels.filter(b => b.alive).length >= this.cfg.maxBarrels) return;
this.spawnTimer = 0;
this.barrels.push({
x: 200,
y: PLATFORMS[4].y - BARREL_R - PLATFORM_H,
platformIndex: 4,
dir: BARREL_DIR[4],
falling: false,
vy: 0,
alive: true,
cleared: false,
ladderRollsDone: new Set(),
});
this.kongArmRaise = 0.6;
this._playFx('bkBarrel', 0.5);
}
// ─── Barrel update ────────────────────────────────────────────────────────
_updateBarrels(dt) {
const cfg = this.cfg;
for (const b of this.barrels) {
if (!b.alive) continue;
if (b.falling) {
b.vy += cfg.gravity * dt;
b.y += b.vy * dt;
// Land on next platform down
const nextIdx = b.platformIndex - 1;
if (nextIdx < 0) {
b.alive = false;
continue;
}
const nextPlat = PLATFORMS[nextIdx];
const landY = nextPlat.y - BARREL_R - PLATFORM_H;
if (b.y >= landY) {
b.y = landY;
b.falling = false;
b.vy = 0;
b.platformIndex = nextIdx;
b.dir = BARREL_DIR[nextIdx];
b.ladderRollsDone = new Set();
}
} else {
b.x += b.dir * cfg.barrelSpeed * dt;
// 50% chance to fall down a ladder when rolling over one
for (let li = 0; li < LADDERS.length; li++) {
const l = LADDERS[li];
if (l.fromPlatform + 1 !== b.platformIndex) continue;
if (b.ladderRollsDone.has(li)) continue;
if (Math.abs(b.x - l.x) < 32) {
b.ladderRollsDone.add(li);
if (Math.random() < 0.5) {
b.x = l.x;
b.falling = true;
b.vy = 0;
break;
}
}
}
const plat = PLATFORMS[b.platformIndex];
// Fall off edge
if (b.dir > 0 && b.x > plat.xMax - BARREL_R) {
if (b.platformIndex === 0) {
b.alive = false; // off the floor
} else {
b.falling = true;
b.vy = 0;
b.x = plat.xMax - BARREL_R;
}
} else if (b.dir < 0 && b.x < plat.xMin + BARREL_R) {
if (b.platformIndex === 0) {
b.alive = false;
} else {
b.falling = true;
b.vy = 0;
b.x = plat.xMin + BARREL_R;
}
}
}
}
this.barrels = this.barrels.filter(b => b.alive);
if (this.kongArmRaise > 0) this.kongArmRaise -= dt;
}
// ─── Collision ────────────────────────────────────────────────────────────
_checkCollisions() {
if (!this.gameActive) return;
const p = this.player;
const pLeft = p.x - PLAYER_W / 2;
const pRight = p.x + PLAYER_W / 2;
const pTop = p.y;
const pBottom = p.y + PLAYER_H;
const pCenterX = p.x;
for (const b of this.barrels) {
if (!b.alive || b.falling) continue;
// Only check barrels on same or adjacent platform
if (Math.abs(b.platformIndex - p.platformIndex) > 0) continue;
if (b.platformIndex !== p.platformIndex) continue;
const dx = pCenterX - b.x;
const dy = (p.y + PLAYER_H / 2) - b.y;
const dist = Math.hypot(dx, dy);
// Stomp: player falling, feet crossing top of barrel
if (p.state === 'jumping' && p.vy > 0 &&
pBottom >= b.y - BARREL_R && pBottom <= b.y + BARREL_R &&
Math.abs(dx) < BARREL_R + PLAYER_W * 0.5) {
b.alive = false;
if (!b.cleared) {
b.cleared = true;
this.barrelsCleared++;
}
p.vy = -this.cfg.stompBounce;
this._playFx('bkStomp', 0.7);
this._checkWin();
continue;
}
// Jump-over: player jumping, feet above barrel center, laterally overlapping
if (p.state === 'jumping' && !b.cleared &&
pBottom < b.y &&
Math.abs(dx) < BARREL_R + PLAYER_W * 0.6 &&
!p.jumpedOverBarrels.has(b)) {
p.jumpedOverBarrels.add(b);
b.cleared = true;
this.barrelsCleared++;
this._checkWin();
continue;
}
// Side hit: AABB vs circle overlap (not a stomp or jump-over already processed)
if (dist < BARREL_R + Math.min(PLAYER_W, PLAYER_H) * 0.45) {
// Don't penalize if player is clearly above (already scored)
if (p.state === 'jumping' && pBottom < b.y - BARREL_R * 0.5) continue;
this._endGame(false, "BARREL'D!");
return;
}
}
// Clean up jump-over tracking for dead/cleared barrels
for (const b of p.jumpedOverBarrels) {
if (!b.alive || b.cleared) continue; // keep cleared ones out naturally
}
}
_checkWin() {
if (this.barrelsCleared >= this.cfg.barrelsToPass) {
this._endGame(true, 'CLEARED!');
}
}
// ─── Drawing ──────────────────────────────────────────────────────────────
_draw() {
const g = this.mainGfx;
g.clear();
this._drawPlatforms(g);
this._drawLadders(g);
this._drawBarrels(g);
this._drawPlayer(g);
}
_drawBackground(g) {
g.fillStyle(0x00060f);
g.fillRect(0, 0, 1600, 900);
g.lineStyle(1, 0x0a1a2a, 0.5);
for (let x = 0; x < 1600; x += 80) g.lineBetween(x, 0, x, 900);
for (let y = 0; y < 900; y += 60) g.lineBetween(0, y, 1600, y);
}
// ─── Kong sprite ──────────────────────────────────────────────────────────
_updateKongSprite(dt) {
if (this.kongArmRaise > 0) {
// Throwing — invalidate any in-flight idle sequence
this.kongIdleSeq++;
this.kongIsIdling = false;
this.kongIdleTimer = 0;
this.kongSprite.setFrame(1);
} else {
if (!this.kongIsIdling) {
this.kongSprite.setFrame(0);
this.kongIdleTimer += dt;
if (this.kongIdleTimer >= this.kongIdleInterval) {
this._playKongIdleAnimation();
}
}
}
}
_playKongIdleAnimation() {
this.kongIsIdling = true;
this.kongIdleTimer = 0;
const seq = ++this.kongIdleSeq;
this.kongSprite.setFrame(2);
this.time.delayedCall(450, () => {
if (!this.gameActive || this.kongIdleSeq !== seq) return;
this.kongSprite.setFrame(3);
this.time.delayedCall(450, () => {
if (!this.gameActive || this.kongIdleSeq !== seq) return;
this.kongSprite.setFrame(0);
this.kongIsIdling = false;
});
});
}
_drawPlatforms(g) {
for (const plat of PLATFORMS) {
const w = plat.xMax - plat.xMin;
// Platform body
g.fillStyle(0x2255bb);
g.fillRect(plat.xMin, plat.y - PLATFORM_H, w, PLATFORM_H);
// Bright top edge
g.lineStyle(3, 0x44aaff, 1.0);
g.lineBetween(plat.xMin, plat.y - PLATFORM_H, plat.xMax, plat.y - PLATFORM_H);
}
}
_drawLadders(g) {
for (const l of LADDERS) {
const lx = l.x - LADDER_W / 2;
const rx = l.x + LADDER_W / 2;
g.lineStyle(3, 0xffcc44, 0.9);
g.lineBetween(lx, l.yBottom - PLATFORM_H, lx, l.yTop);
g.lineBetween(rx, l.yBottom - PLATFORM_H, rx, l.yTop);
// Rungs
g.lineStyle(2, 0xffcc44, 0.7);
const rungCount = Math.floor((l.yBottom - l.yTop) / 20);
for (let r = 0; r <= rungCount; r++) {
const ry = l.yTop + r * ((l.yBottom - PLATFORM_H - l.yTop) / rungCount);
g.lineBetween(lx, ry, rx, ry);
}
}
}
_drawBarrels(g) {
for (const b of this.barrels) {
if (!b.alive) continue;
const r = BARREL_R;
// Barrel body
g.fillStyle(0xaa5500);
g.fillCircle(b.x, b.y, r);
// Cross-lines
g.lineStyle(2, 0xffaa44, 0.9);
g.lineBetween(b.x - r + 3, b.y, b.x + r - 3, b.y);
g.lineBetween(b.x, b.y - r + 3, b.x, b.y + r - 3);
// Outline
g.lineStyle(1.5, 0xff8800, 0.7);
g.strokeCircle(b.x, b.y, r);
}
}
_drawPlayer(g) {
const p = this.player;
const x = p.x, y = p.y;
// Body
g.fillStyle(0xdd4411);
g.fillRect(x - PLAYER_W / 2, y, PLAYER_W, PLAYER_H * 0.65);
// Head
g.fillStyle(0xffaa77);
g.fillCircle(x, y - 2, PLAYER_W * 0.42);
// Facing direction indicator (small triangle on chest)
g.fillStyle(0xffff00);
const tx = x + p.facingDir * (PLAYER_W * 0.35);
const ty = y + PLAYER_H * 0.3;
g.fillTriangle(
tx, ty,
tx - p.facingDir * 8, ty - 6,
tx - p.facingDir * 8, ty + 6
);
// Legs
g.fillStyle(0x994400);
g.fillRect(x - PLAYER_W / 2, y + PLAYER_H * 0.65, PLAYER_W * 0.4, PLAYER_H * 0.35);
g.fillRect(x + PLAYER_W * 0.1, y + PLAYER_H * 0.65, PLAYER_W * 0.4, PLAYER_H * 0.35);
}
// ─── HUD ──────────────────────────────────────────────────────────────────
_buildHUD() {
this.livesText = this.add.text(340, 16, '', {
fontSize: '26px', fontFamily: 'ArcadeClassic, monospace',
color: '#ff4444', stroke: '#000000', strokeThickness: 4,
}).setDepth(10);
this.barrelsText = this.add.text(800, 16, '', {
fontSize: '26px', fontFamily: 'ArcadeClassic, monospace',
color: '#ffcc44', stroke: '#000000', strokeThickness: 4,
}).setOrigin(0.5, 0).setDepth(10);
this.levelText = this.add.text(1580, 16, `LEVEL ${this.level}`, {
fontSize: '26px', fontFamily: 'ArcadeClassic, monospace',
color: '#00ccff', stroke: '#000000', strokeThickness: 4,
}).setOrigin(1, 0).setDepth(10);
this.hintText = this.add.text(800, 875, 'A: Jump ↑↓: Climb Reach the top or clear barrels!', {
fontSize: '18px', fontFamily: 'ArcadeClassic, monospace',
color: '#446688', stroke: '#000000', strokeThickness: 3,
}).setOrigin(0.5, 1).setDepth(10);
}
_updateHUD() {
this.livesText.setText('♥ '.repeat(this.lives).trim());
this.barrelsText.setText(`BARRELS ${this.barrelsCleared} / ${this.cfg.barrelsToPass}`);
}
// ─── End game ─────────────────────────────────────────────────────────────
_endGame(success, msg) {
if (!this.gameActive) return;
this.gameActive = false;
if (success) {
this._playFx('bkComplete', 0.8);
this.kongSprite.setFrame(4);
} else {
this._playFx('bkHit', 0.7);
}
const color = success ? '#00ff44' : '#ff2222';
this.add.text(800, 440, msg, {
fontSize: '80px', fontFamily: 'ArcadeClassic, monospace',
color, stroke: '#000000', strokeThickness: 6,
}).setOrigin(0.5).setDepth(20);
this.time.delayedCall(2200, () => {
this.game.events.emit('miniGameResult', { success });
});
}
shutdown() {
stopMusic(this);
}
_playFx(key, volume = 0.6) {
const snd = this.sound.add(key, { volume });
snd.once('complete', () => snd.destroy());
snd.play();
}
}