Files
awesome-copilot/extensions/arcade-canvas/game/scenes/NinjaRunner.js
T
2026-06-17 09:29:58 +00:00

2811 lines
119 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.
// NinjaRunner — side-scrolling platformer with free JuhoSprite assets.
// Extracted from the original monolithic game.ts and refactored to
// extend BaseScene for the multi-game architecture.
import { BaseScene, W, H } from './BaseScene.js';
const BLOCK = 48; // logical world tile size
const PLAYER_W = 48; // player draw size
const PLAYER_H = 48; // player draw size
const SPAWN_X = 600;
// Computed dynamically so it uses the correct H after refreshDimensions()
function getGroundY() { return H - BLOCK; }
let GROUND_Y = H - BLOCK; // will be updated in create()
export class NinjaRunnerScene extends BaseScene {
// Input
cursors;
keys;
// Player state
player;
isBig = false;
facingRight = true;
invincible = 1500; // ms
shrinkTimer = 0;
stompGrace = 0;
dead = false;
deadTimer = 0;
lastSafeX = SPAWN_X;
fireCooldown = 0;
// Jump tracking — manual edge detection is more reliable on macOS than
// Phaser's JustDown when multiple keys are held simultaneously.
jumpKeyWasDown = false;
coyoteTime = 0; // ms left where we can still jump after leaving ground
jumpBuffer = 0; // ms left where a queued jump press will fire on landing
canDoubleJump = false;
hasDoubleJumped = false;
// Animation: cycle the run frame based on distance traveled, not wall time,
// so step rhythm matches actual movement speed.
runDistance = 0;
// Generation
genX = 0;
// Groups
groundGroup;
brickGroup;
qblockGroup;
pipeGroup;
coinGroup;
mushroomGroup;
heartGroup;
fireballGroup;
enemyGroup;
gaps = [];
bridgeGroup;
bounceGroup;
flagGroup;
currentLevel = 1;
currentBiome = 0;
distanceSinceFlag = 0;
piranhaGroup;
fireGroup;
crocGroup;
fishGroup;
warping = false;
parachuteMode = false;
parachuteSprite;
parachuteFlyingEnemies = [];
parachuteTimer = 0;
windSound;
glowSprite;
constructor() { super('ninja-runner'); }
get displayName() { return 'Ninja Runner'; }
getDescription() {
return 'Run, jump, and dash through endless obstacles. How far can you go?';
}
getControls() {
return [
{ key: '← →', action: 'Move Left / Right' },
{ key: 'SPACE', action: 'Jump' },
{ key: 'SHIFT', action: 'Run' },
{ key: 'F', action: 'Fireball' },
{ key: 'Z', action: 'Stomp Attack' },
];
}
sfx(key, volume = 0.3) {
try {
this.sound.play(key, { volume });
}
catch { /* ignore audio errors */ }
}
preload() {
// Player spritesheet: 7 frames of 16×16
this.load.spritesheet('player', '../assets/ninja-runner/player_strip.png', { frameWidth: 16, frameHeight: 16 });
// Enemy spritesheet: 5 frames of 16×16
this.load.spritesheet('enemy', '../assets/ninja-runner/enemy_strip.png', { frameWidth: 16, frameHeight: 16 });
// Coin animation: 4 frames of 16×16
this.load.spritesheet('coin_anim', '../assets/ninja-runner/coin_sheet.png', { frameWidth: 16, frameHeight: 16 });
// Heart pickup
this.load.spritesheet('heart_anim', '../assets/ninja-runner/heart_sheet.png', { frameWidth: 16, frameHeight: 16 });
// Tile textures
this.load.image('grass_block', '../assets/ninja-runner/grass_block.png');
this.load.image('dirt_block', '../assets/ninja-runner/dirt_block.png');
this.load.image('brown_block', '../assets/ninja-runner/brown_block.png');
this.load.image('qblock_img', '../assets/ninja-runner/qblock_new.png');
this.load.image('platform_tile', '../assets/ninja-runner/platform.png');
this.load.image('spikes_tile', '../assets/ninja-runner/spikes.png');
this.load.image('flag_tile', '../assets/ninja-runner/flag.png');
this.load.image('bridge_tile', '../assets/ninja-runner/bridge.png');
this.load.image('impact', '../assets/ninja-runner/impact_sheet.png');
this.load.image('clouds', '../assets/ninja-runner/clouds.png');
this.load.image('hill_0', '../assets/ninja-runner/hill_0.png');
this.load.image('hill_1', '../assets/ninja-runner/hill_1.png');
this.load.image('big_bush', '../assets/ninja-runner/big_bush.png');
this.load.image('small_bush', '../assets/ninja-runner/small_bush.png');
this.load.image('background', '../assets/ninja-runner/background.png');
this.load.spritesheet('enemy_tall', '../assets/ninja-runner/enemy_tall_strip.png', { frameWidth: 16, frameHeight: 32 });
this.load.spritesheet('enemy_short', '../assets/ninja-runner/enemy_short_strip.png', { frameWidth: 16, frameHeight: 16 });
// Sound effects
this.load.audio('nr_jump', '../assets/ninja-runner/sounds/SoundJump1.m4a');
this.load.audio('nr_coin', '../assets/ninja-runner/sounds/SoundCoin.m4a');
this.load.audio('nr_stomp', '../assets/ninja-runner/sounds/SoundEnemyDeath.m4a');
this.load.audio('nr_powerup', '../assets/ninja-runner/sounds/SoundBonus.m4a');
this.load.audio('nr_hit', '../assets/ninja-runner/sounds/SoundPlayerHit.m4a');
this.load.audio('nr_die', '../assets/ninja-runner/sounds/SoundDeath.m4a');
this.load.audio('nr_flag', '../assets/ninja-runner/sounds/SoundReachGoal.m4a');
this.load.audio('nr_bounce', '../assets/ninja-runner/sounds/SoundBounce.m4a');
this.load.audio('nr_startlevel', '../assets/ninja-runner/sounds/SoundStartLevel.m4a');
this.load.audio('nr_gameover', '../assets/ninja-runner/sounds/SoundGameOver.m4a');
this.load.audio('nr_land', '../assets/ninja-runner/sounds/SoundLand1.m4a');
this.load.audio('nr_flap', '../assets/ninja-runner/sounds/SoundFlapLight.m4a');
this.load.audio('nr_warp', '../assets/ninja-runner/sounds/SoundOpenDoor.m4a');
this.load.audio('nr_fireball', '../assets/ninja-runner/sounds/SoundShootRegular.m4a');
this.load.audio('nr_explosion', '../assets/ninja-runner/sounds/SoundExplosionSmall.m4a');
this.load.audio('nr_extralife', '../assets/ninja-runner/sounds/SoundSpecialSkill.m4a');
this.load.audio('nr_wind', '../assets/ninja-runner/sounds/SoundWind.m4a');
}
create() {
this.initBase();
// Recompute GROUND_Y from the actual game height (H may have been
// refreshed after module load by refreshDimensions in game.ts)
GROUND_Y = H - BLOCK;
this.makeBlockTextures();
this.physics.world.setBounds(0, 0, 1_000_000, H);
this.groundGroup = this.physics.add.staticGroup();
this.brickGroup = this.physics.add.staticGroup();
this.qblockGroup = this.physics.add.staticGroup();
this.pipeGroup = this.physics.add.staticGroup();
this.coinGroup = this.physics.add.group({ allowGravity: false });
this.mushroomGroup = this.physics.add.group();
this.heartGroup = this.physics.add.group({ allowGravity: false });
this.fireballGroup = this.physics.add.group();
this.enemyGroup = this.physics.add.group();
this.piranhaGroup = this.physics.add.group({ allowGravity: false });
this.bridgeGroup = this.physics.add.staticGroup();
this.bounceGroup = this.physics.add.staticGroup();
this.flagGroup = this.physics.add.staticGroup();
this.fireGroup = this.physics.add.group({ allowGravity: false });
this.crocGroup = this.physics.add.group({ allowGravity: false });
this.fishGroup = this.physics.add.group({ allowGravity: false });
// Initial ground
this.extendGround(0, W * 2);
// Player — spritesheet frame 0 = idle
this.player = this.physics.add.sprite(SPAWN_X, GROUND_Y - 200, 'player', 0);
this.player.setOrigin(0.5, 1);
this.player.setDisplaySize(PLAYER_W, PLAYER_H);
// Physics body fills the full cell so player's head hits blocks above.
this.player.body.setSize(12, 16);
this.player.body.setOffset(2, 0);
this.player.setMaxVelocity(700, 900);
this.player.body.setGravityY(1800);
this.player.setDepth(10);
// Player animations
this.anims.create({
key: 'player_walk',
frames: this.anims.generateFrameNumbers('player', { frames: [1, 2, 3] }),
frameRate: 10,
repeat: -1,
});
this.anims.create({
key: 'player_idle',
frames: [{ key: 'player', frame: 0 }],
frameRate: 1,
});
// Coin spin animation
this.anims.create({
key: 'coin_spin',
frames: this.anims.generateFrameNumbers('coin_anim', { start: 0, end: 3 }),
frameRate: 8,
repeat: -1,
});
// Heart pulse animation
this.anims.create({
key: 'heart_pulse',
frames: this.anims.generateFrameNumbers('heart_anim', { start: 0, end: 3 }),
frameRate: 4,
repeat: -1,
});
// Enemy walk
this.anims.create({
key: 'enemy_walk',
frames: this.anims.generateFrameNumbers('enemy', { frames: [0, 1, 2, 3] }),
frameRate: 6,
repeat: -1,
});
this.anims.create({
key: 'enemy_tall_walk',
frames: this.anims.generateFrameNumbers('enemy_tall', { frames: [0, 1, 2, 3] }),
frameRate: 6,
repeat: -1,
});
this.anims.create({
key: 'enemy_short_walk',
frames: this.anims.generateFrameNumbers('enemy_short', { frames: [0, 1, 2, 3] }),
frameRate: 8,
repeat: -1,
});
// Camera
this.cameras.main.setBounds(0, 0, 1_000_000, H);
this.cameras.main.startFollow(this.player, true, 0.15, 0.05, -W * 0.2, 0);
this.cameras.main.setBackgroundColor('rgba(0,0,0,0)');
// Colliders
this.physics.add.collider(this.player, this.groundGroup);
this.physics.add.collider(this.player, this.brickGroup, this.onPlayerHitBrick, undefined, this);
this.physics.add.collider(this.player, this.qblockGroup, this.onPlayerHitQBlock, undefined, this);
this.physics.add.collider(this.player, this.pipeGroup);
this.physics.add.collider(this.enemyGroup, this.groundGroup);
this.physics.add.collider(this.enemyGroup, this.brickGroup);
this.physics.add.collider(this.enemyGroup, this.qblockGroup);
this.physics.add.collider(this.enemyGroup, this.pipeGroup);
this.physics.add.overlap(this.enemyGroup, this.enemyGroup, this.onEnemyVsEnemy, undefined, this);
this.physics.add.collider(this.mushroomGroup, this.groundGroup);
this.physics.add.collider(this.mushroomGroup, this.brickGroup);
this.physics.add.collider(this.mushroomGroup, this.qblockGroup);
this.physics.add.collider(this.mushroomGroup, this.pipeGroup);
this.physics.add.collider(this.fireballGroup, this.groundGroup, this.onFireballHitSolid, undefined, this);
this.physics.add.collider(this.fireballGroup, this.brickGroup, this.onFireballHitSolid, undefined, this);
this.physics.add.collider(this.fireballGroup, this.qblockGroup, this.onFireballHitSolid, undefined, this);
this.physics.add.collider(this.fireballGroup, this.pipeGroup, this.onFireballHitSolid, undefined, this);
this.physics.add.overlap(this.player, this.coinGroup, this.onPlayerCoin, undefined, this);
this.physics.add.overlap(this.player, this.mushroomGroup, this.onPlayerMushroom, undefined, this);
this.physics.add.overlap(this.player, this.heartGroup, this.onPlayerHeart, undefined, this);
this.physics.add.overlap(this.player, this.enemyGroup, this.onPlayerEnemy, undefined, this);
this.physics.add.overlap(this.fireballGroup, this.enemyGroup, this.onFireballEnemy, undefined, this);
this.physics.add.overlap(this.player, this.piranhaGroup, this.onPlayerPiranha, undefined, this);
this.physics.add.overlap(this.player, this.fireGroup, this.onPlayerFire, undefined, this);
this.physics.add.overlap(this.player, this.crocGroup, this.onPlayerCroc, undefined, this);
this.physics.add.overlap(this.player, this.fishGroup, this.onPlayerFish, undefined, this);
this.physics.add.collider(this.player, this.bridgeGroup, this.onPlayerBridge, undefined, this);
this.physics.add.collider(this.enemyGroup, this.bridgeGroup);
this.physics.add.overlap(this.player, this.flagGroup, this.onPlayerFlag, undefined, this);
this.physics.add.collider(this.player, this.bounceGroup, this.onPlayerBounce, undefined, this);
this.physics.add.collider(this.enemyGroup, this.bounceGroup);
// Input
this.input.keyboard.addCapture('UP,DOWN,LEFT,RIGHT,SPACE,SHIFT,F,Z');
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = {
space: this.input.keyboard.addKey('SPACE'),
shift: this.input.keyboard.addKey('SHIFT'),
f: this.input.keyboard.addKey('F'),
z: this.input.keyboard.addKey('Z'),
};
this.addDecorations();
this.generateLevel(SPAWN_X + 400, W + 600);
this.syncLivesToHUD();
this.loadHighScore();
this.distanceSinceFlag = 0;
this.currentLevel = 1;
this.syncLevelToHUD(this.currentLevel);
this.sfx('nr_startlevel', 0.25);
this.startWithReadyScreen();
}
// ---------- Brick / block textures generated at runtime via Graphics ----------
makeBlockTextures() {
const g = this.add.graphics();
// Used Q-block (brown/empty) — 16×16 to match source tile size
g.clear();
g.fillStyle(0xa56a26);
g.fillRect(0, 0, 16, 16);
g.fillStyle(0x6e4715);
g.fillRect(0, 0, 16, 1);
g.fillRect(0, 15, 16, 1);
g.fillRect(0, 0, 1, 16);
g.fillRect(15, 0, 1, 16);
g.generateTexture('qblock_used', 16, 16);
// Pipe body (2 blocks wide)
g.clear();
g.fillStyle(0x20a010);
g.fillRect(0, 0, BLOCK * 2, BLOCK);
g.fillStyle(0x00680c);
g.lineStyle(2, 0x00680c);
g.strokeRect(0, 0, BLOCK * 2, BLOCK);
g.fillStyle(0x80e080);
g.fillRect(BLOCK / 3 + 4, 0, 4, BLOCK);
g.generateTexture('pipe_body', BLOCK * 2, BLOCK);
// Mushroom
g.clear();
g.fillStyle(0xd02020);
g.fillRect(2, 2, 28, 16);
g.fillStyle(0xffffff);
g.fillRect(8, 6, 6, 6);
g.fillRect(18, 6, 6, 6);
g.fillStyle(0xf0d8a0);
g.fillRect(6, 18, 20, 12);
g.fillStyle(0x000000);
g.fillRect(11, 22, 3, 4);
g.fillRect(18, 22, 3, 4);
g.generateTexture('mushroom', 32, 32);
// Fireball
g.clear();
g.fillStyle(0xff8000);
g.fillCircle(8, 8, 7);
g.fillStyle(0xffe080);
g.fillCircle(6, 6, 3);
g.generateTexture('fireball', 16, 16);
// Fire eruption — organic flame shape with layered colors
g.clear();
// Outer flame (dark red)
g.fillStyle(0xcc2200);
g.fillEllipse(8, 24, 14, 16);
g.fillEllipse(8, 14, 10, 14);
g.fillEllipse(8, 6, 6, 10);
// Middle flame (orange)
g.fillStyle(0xff6600);
g.fillEllipse(8, 26, 10, 12);
g.fillEllipse(8, 16, 8, 12);
g.fillEllipse(8, 8, 4, 8);
// Inner flame (yellow core)
g.fillStyle(0xffcc00);
g.fillEllipse(8, 28, 6, 8);
g.fillEllipse(8, 20, 4, 8);
// Hot white tip
g.fillStyle(0xffffaa);
g.fillEllipse(8, 28, 3, 5);
g.generateTexture('fire_column', 16, 32);
// Piranha plant frame 0 (mouth closed)
g.clear();
g.fillStyle(0x22aa22);
g.fillRect(11, 16, 10, 16);
g.fillStyle(0xdd2020);
g.fillEllipse(16, 10, 24, 16);
g.fillStyle(0xffffff);
g.fillCircle(10, 8, 2);
g.fillCircle(16, 6, 2);
g.fillCircle(22, 8, 2);
g.generateTexture('piranha_0', 32, 32);
// Piranha plant frame 1 (mouth open)
g.clear();
g.fillStyle(0x22aa22);
g.fillRect(11, 18, 10, 14);
g.fillStyle(0xdd2020);
g.fillEllipse(16, 10, 26, 18);
g.fillStyle(0xffffff);
g.fillCircle(10, 7, 2);
g.fillCircle(16, 5, 2);
g.fillCircle(22, 7, 2);
g.fillStyle(0x000000);
g.fillRect(8, 12, 16, 3);
g.generateTexture('piranha_1', 32, 32);
// Power-up glow effect
g.clear();
g.fillStyle(0xffdd00, 0.3);
g.fillCircle(20, 20, 20);
g.fillStyle(0xffff88, 0.2);
g.fillCircle(20, 20, 14);
g.generateTexture('glow', 40, 40);
// Individual cloud puffs (3 sizes for variety)
g.clear();
g.fillStyle(0xffffff);
g.fillCircle(10, 10, 8);
g.fillCircle(20, 8, 10);
g.fillCircle(32, 10, 9);
g.fillCircle(16, 14, 7);
g.fillCircle(26, 14, 8);
g.generateTexture('cloud_sm', 42, 22);
g.clear();
g.fillStyle(0xffffff);
g.fillCircle(14, 14, 12);
g.fillCircle(30, 10, 14);
g.fillCircle(48, 14, 11);
g.fillCircle(22, 18, 10);
g.fillCircle(38, 18, 12);
g.generateTexture('cloud_md', 60, 28);
g.clear();
g.fillStyle(0xffffff);
g.fillCircle(16, 16, 14);
g.fillCircle(36, 12, 16);
g.fillCircle(58, 14, 13);
g.fillCircle(24, 22, 12);
g.fillCircle(46, 20, 14);
g.fillCircle(70, 16, 10);
g.generateTexture('cloud_lg', 82, 32);
// Green bat enemy — flies in a wave pattern
g.clear();
g.fillStyle(0x22aa44);
g.fillEllipse(8, 9, 8, 8);
g.fillStyle(0x44dd66);
g.fillTriangle(1, 6, 6, 8, 3, 12); // left wing
g.fillTriangle(15, 6, 10, 8, 13, 12); // right wing
g.fillStyle(0xff0000);
g.fillCircle(6, 8, 1);
g.fillCircle(10, 8, 1);
g.generateTexture('bat_0', 16, 16);
g.clear();
g.fillStyle(0x22aa44);
g.fillEllipse(8, 9, 8, 8);
g.fillStyle(0x44dd66);
g.fillTriangle(1, 10, 6, 8, 3, 4); // wings up
g.fillTriangle(15, 10, 10, 8, 13, 4);
g.fillStyle(0xff0000);
g.fillCircle(6, 8, 1);
g.fillCircle(10, 8, 1);
g.generateTexture('bat_1', 16, 16);
// Warp pipe (lighter green with down arrow)
g.clear();
g.fillStyle(0x30c030);
g.fillRect(0, 0, BLOCK * 2, BLOCK);
g.fillStyle(0x10a010);
g.lineStyle(2, 0x10a010);
g.strokeRect(0, 0, BLOCK * 2, BLOCK);
g.fillStyle(0xa0ffa0);
g.fillRect(BLOCK / 3 + 4, 0, 4, BLOCK);
g.fillStyle(0xffffff);
g.fillTriangle(BLOCK, 4, BLOCK - 6, BLOCK / 2 - 4, BLOCK + 6, BLOCK / 2 - 4);
g.generateTexture('pipe_warp', BLOCK * 2, BLOCK);
// Golden pipe (parachute trigger)
g.clear();
g.fillStyle(0xdaa520);
g.fillRect(0, 0, BLOCK * 2, BLOCK);
g.fillStyle(0xb8860b);
g.lineStyle(2, 0xb8860b);
g.strokeRect(0, 0, BLOCK * 2, BLOCK);
g.fillStyle(0xffd700);
g.fillRect(BLOCK / 3 + 4, 0, 4, BLOCK);
g.fillStyle(0xffffff);
g.fillTriangle(BLOCK, BLOCK / 2 - 2, BLOCK - 5, BLOCK / 2 + 6, BLOCK + 5, BLOCK / 2 + 6);
g.generateTexture('pipe_gold', BLOCK * 2, BLOCK);
// Parachute canopy — half-dome with red/white panels, scalloped rim, strings
g.clear();
const cw = 64, ch = 80;
const domeBottom = 36; // y where the canopy ends
// Draw dome as upper half only — fill a tall ellipse then cover the bottom half
g.fillStyle(0xff2020);
g.fillEllipse(cw / 2, domeBottom, cw - 4, 56); // tall ellipse centered at rim
// Cover lower half so only the dome (upper half) remains
g.fillStyle(0x000000, 0.0);
// We can't erase, so draw the dome differently:
// Use a filled arc approach — draw overlapping circles for dome shape
g.clear();
// Red canopy dome — build with filled upper-half ellipse
// Panel 1 (red) — left
g.fillStyle(0xff2020);
g.fillRoundedRect(2, 4, 14, domeBottom - 4, { tl: 10, tr: 4, bl: 0, br: 0 });
// Panel 2 (white)
g.fillStyle(0xffffff);
g.fillRoundedRect(16, 2, 10, domeBottom - 2, { tl: 6, tr: 6, bl: 0, br: 0 });
// Panel 3 (red) — center
g.fillStyle(0xff2020);
g.fillRoundedRect(26, 1, 12, domeBottom - 1, { tl: 8, tr: 8, bl: 0, br: 0 });
// Panel 4 (white)
g.fillStyle(0xffffff);
g.fillRoundedRect(38, 2, 10, domeBottom - 2, { tl: 6, tr: 6, bl: 0, br: 0 });
// Panel 5 (red) — right
g.fillStyle(0xff2020);
g.fillRoundedRect(48, 4, 14, domeBottom - 4, { tl: 4, tr: 10, bl: 0, br: 0 });
// Top cap to round off the top
g.fillStyle(0xff2020);
g.fillEllipse(cw / 2, 6, 36, 12);
// Scalloped bottom edge — small arcs to suggest billowy fabric
g.fillStyle(0xff2020);
for (let sx = 5; sx < cw - 4; sx += 12) {
g.fillEllipse(sx + 6, domeBottom, 13, 6);
}
// Dark rim outline along bottom edge
g.lineStyle(2, 0x880000);
g.lineBetween(2, domeBottom, cw - 2, domeBottom);
// Panel divider lines
g.lineStyle(1, 0xaa0000);
g.lineBetween(16, 6, 16, domeBottom);
g.lineBetween(26, 4, 26, domeBottom);
g.lineBetween(38, 4, 38, domeBottom);
g.lineBetween(48, 6, 48, domeBottom);
// Outer rim outline
g.lineStyle(2, 0x880000);
g.strokeRoundedRect(2, 2, cw - 4, domeBottom, { tl: 14, tr: 14, bl: 0, br: 0 });
// Strings — fan out from canopy rim to a gather point near player
g.lineStyle(1, 0x654321);
const gatherY = ch - 2;
const gatherX = cw / 2;
g.lineBetween(4, domeBottom + 2, gatherX - 4, gatherY);
g.lineBetween(16, domeBottom + 2, gatherX - 2, gatherY);
g.lineBetween(cw / 2, domeBottom + 2, gatherX, gatherY);
g.lineBetween(48, domeBottom + 2, gatherX + 2, gatherY);
g.lineBetween(cw - 4, domeBottom + 2, gatherX + 4, gatherY);
g.generateTexture('parachute', cw, ch);
// Coin frame 0 (circle)
g.clear();
g.fillStyle(0xffd24a);
g.fillCircle(12, 12, 9);
g.fillStyle(0xb88a1f);
g.fillRect(11, 3, 2, 18);
g.generateTexture('coin0', 24, 24);
// Coin frame 1 (thin)
g.clear();
g.fillStyle(0xffd24a);
g.fillRect(9, 3, 6, 18);
g.fillStyle(0xb88a1f);
g.fillRect(11, 3, 2, 18);
g.generateTexture('coin1', 24, 24);
// Water tile — blue gradient with a subtle wave highlight
g.clear();
g.fillStyle(0x1a5276);
g.fillRect(0, 0, BLOCK, BLOCK);
g.fillStyle(0x2471a3);
g.fillRect(0, 0, BLOCK, BLOCK * 0.3);
g.fillStyle(0x85c1e9, 0.5);
g.fillRect(4, 2, BLOCK * 0.3, 3);
g.fillStyle(0x85c1e9, 0.4);
g.fillRect(BLOCK * 0.55, 6, BLOCK * 0.25, 2);
g.generateTexture('water', BLOCK, BLOCK);
// Crocodile — Side view with tail, head poking above water
// Both textures share the same back/body y-positions so swapping doesn't
// make the croc rise out of the water.
const crW = 64, crH = 22;
const backY = 6; // top of back ridge — same in both states
// Mouth closed (safe to stomp)
g.clear();
// Tail — tapers to the left
g.fillStyle(0x3d5c1e);
g.fillTriangle(0, backY + 4, 14, backY + 2, 14, backY + 8);
g.fillStyle(0x2d4a14);
g.fillTriangle(0, backY + 4, 8, backY + 3, 8, backY + 6); // darker tip
// Tail ridges
g.lineStyle(1, 0x2d4a14);
g.lineBetween(4, backY + 3, 4, backY + 6);
g.lineBetween(8, backY + 2, 8, backY + 7);
// Body/back — long green shape
g.fillStyle(0x3d5c1e);
g.fillRoundedRect(12, backY, crW - 12, 12, { tl: 3, tr: 2, bl: 3, br: 2 });
// Snout — extends forward (right side)
g.fillStyle(0x4a6e23);
g.fillRoundedRect(crW - 18, backY + 2, 18, 8, { tl: 0, tr: 3, bl: 0, br: 3 });
// Darker dorsal ridge with bumps
g.fillStyle(0x2d4a14);
g.fillRect(14, backY, crW - 32, 3);
for (let bx = 16; bx < crW - 20; bx += 6) {
g.fillRect(bx, backY + 1, 3, 2);
}
// Nostril
g.fillStyle(0x1a2e0a);
g.fillCircle(crW - 4, backY + 5, 1);
// Eye — yellow with black pupil
g.fillStyle(0xffdd00);
g.fillCircle(crW - 20, backY + 4, 3);
g.fillStyle(0x111111);
g.fillCircle(crW - 19, backY + 4, 1.5);
// Jaw line
g.lineStyle(1, 0x2d4a14);
g.lineBetween(crW - 18, backY + 8, crW - 2, backY + 8);
// Teeth hints along closed jaw
g.fillStyle(0xeeeeee);
for (let tx = crW - 16; tx < crW - 2; tx += 4) {
g.fillTriangle(tx, backY + 8, tx + 2, backY + 8, tx + 1, backY + 10);
}
g.generateTexture('croc_closed', crW, crH);
// Mouth open (danger!) — back stays at same y, only jaws move
g.clear();
// Tail — same as closed
g.fillStyle(0x3d5c1e);
g.fillTriangle(0, backY + 4, 14, backY + 2, 14, backY + 8);
g.fillStyle(0x2d4a14);
g.fillTriangle(0, backY + 4, 8, backY + 3, 8, backY + 6);
g.lineStyle(1, 0x2d4a14);
g.lineBetween(4, backY + 3, 4, backY + 6);
g.lineBetween(8, backY + 2, 8, backY + 7);
// Body/back — same position as closed
g.fillStyle(0x3d5c1e);
g.fillRoundedRect(12, backY, crW - 30, 10, { tl: 3, tr: 2, bl: 3, br: 2 });
// Dorsal ridge — same
g.fillStyle(0x2d4a14);
g.fillRect(14, backY, crW - 32, 3);
for (let bx = 16; bx < crW - 20; bx += 6) {
g.fillRect(bx, backY + 1, 3, 2);
}
// Upper jaw — tilted up from back line
g.fillStyle(0x4a6e23);
g.fillRoundedRect(crW - 18, backY - 2, 18, 6, { tl: 0, tr: 3, bl: 0, br: 0 });
// Lower jaw — drops down into water
g.fillStyle(0x4a6e23);
g.fillRoundedRect(crW - 18, backY + 10, 18, 6, { tl: 0, tr: 0, bl: 0, br: 3 });
// Red mouth interior
g.fillStyle(0xcc2222);
g.fillRect(crW - 16, backY + 4, 14, 6);
// Upper teeth
g.fillStyle(0xffffff);
for (let tx = crW - 16; tx < crW - 2; tx += 4) {
g.fillTriangle(tx, backY + 4, tx + 2, backY + 4, tx + 1, backY + 6);
}
// Lower teeth
for (let tx = crW - 16; tx < crW - 2; tx += 4) {
g.fillTriangle(tx, backY + 10, tx + 2, backY + 10, tx + 1, backY + 8);
}
// Nostril
g.fillStyle(0x1a2e0a);
g.fillCircle(crW - 4, backY - 1, 1);
// Eye — yellow with black pupil (same as closed)
g.fillStyle(0xffdd00);
g.fillCircle(crW - 20, backY + 1, 3);
g.fillStyle(0x111111);
g.fillCircle(crW - 19, backY + 1, 1.5);
g.generateTexture('croc_open', crW, crH);
// Fish — small side-view fish for bridge gaps
const fW = 20, fH = 14;
g.clear();
// Body — orange/gold oval
g.fillStyle(0xff8800);
g.fillRoundedRect(2, 3, fW - 6, fH - 6, 4);
// Belly highlight
g.fillStyle(0xffbb44);
g.fillRoundedRect(4, 6, fW - 10, 4, 2);
// Tail fin
g.fillStyle(0xff6600);
g.fillTriangle(0, 3, 0, fH - 3, 5, fH / 2);
// Dorsal fin
g.fillStyle(0xff6600);
g.fillTriangle(8, 3, 14, 3, 11, 0);
// Eye
g.fillStyle(0xffffff);
g.fillCircle(fW - 7, 6, 2);
g.fillStyle(0x111111);
g.fillCircle(fW - 6, 6, 1);
// Mouth
g.lineStyle(1, 0xcc4400);
g.lineBetween(fW - 3, 7, fW - 1, 7);
g.generateTexture('fish', fW, fH);
// Bounce pad (spring block)
g.clear();
g.fillStyle(0xff6600);
g.fillRect(0, 0, BLOCK, BLOCK);
g.fillStyle(0xff9933);
g.fillRect(4, 4, BLOCK - 8, BLOCK / 3);
g.fillStyle(0xcc4400);
g.fillRect(0, 0, BLOCK, 2);
g.fillRect(0, BLOCK - 2, BLOCK, 2);
g.fillRect(0, 0, 2, BLOCK);
g.fillRect(BLOCK - 2, 0, 2, BLOCK);
g.fillStyle(0xffcc00);
g.fillRect(BLOCK / 4, BLOCK / 3, BLOCK / 2, 4);
g.fillRect(BLOCK / 4, BLOCK / 3 + 8, BLOCK / 2, 4);
g.generateTexture('bounce_pad', BLOCK, BLOCK);
g.destroy();
}
addDecorations() {
// Semi-transparent tiled background — mountains peeking through
// The image is 320×180, tile it across a wide area with slow parallax
for (let i = 0; i < 30; i++) {
this.add.image(i * W * 0.5, H / 2, 'background')
.setDisplaySize(W * 0.5, H)
.setAlpha(0.18)
.setScrollFactor(0.05)
.setDepth(-5);
}
// Hills behind the ground — very subtle
for (let i = 0; i < 20; i++) {
const hx = i * 500 + Math.random() * 300;
const isSmall = Math.random() < 0.5;
const tex = isSmall ? 'hill_0' : 'hill_1';
const hh = isSmall ? 64 : 96;
this.add.image(hx, GROUND_Y - hh / 2 + 10, tex)
.setDisplaySize(isSmall ? 64 : 64, hh)
.setAlpha(0.12)
.setScrollFactor(0.3)
.setDepth(-2);
}
// Bushes at ground level — decorative
for (let i = 0; i < 25; i++) {
const bx = i * 400 + Math.random() * 200;
const isBig = Math.random() < 0.4;
const tex = isBig ? 'big_bush' : 'small_bush';
this.add.image(bx, GROUND_Y - 8, tex)
.setDisplaySize(isBig ? 96 : 64, 32)
.setAlpha(0.2)
.setScrollFactor(0.5)
.setDepth(-1);
}
}
extendGround(fromX, toX) {
for (let x = Math.floor(fromX / BLOCK) * BLOCK; x < toX; x += BLOCK) {
if (this.isInGap(x + BLOCK / 2))
continue;
// skip if already there
const exists = this.groundGroup.getChildren().some((g) => Math.abs(g.x - (x + BLOCK / 2)) < 1);
if (exists)
continue;
const g = this.groundGroup.create(x + BLOCK / 2, GROUND_Y + BLOCK / 2, 'grass_block');
g.setDisplaySize(BLOCK, BLOCK);
g.refreshBody();
const BIOME_TINTS = [0xffffff, 0xdec487, 0xb39ddb, 0xb3e5fc];
g.setTint(BIOME_TINTS[this.currentBiome % 4]);
}
}
isInGap(wx) {
for (const gap of this.gaps) {
if (wx >= gap.start && wx < gap.end)
return true;
}
return false;
}
/** Returns true if wx is near any solid obstacle (pipe, brick, qblock, bounce pad). */
isNearObstacle(wx) {
const check = (group) => {
const children = group.getChildren();
for (const p of children) {
if (!p.active)
continue;
if (Math.abs(wx - p.x) < BLOCK * 1.2)
return true;
}
return false;
};
return check(this.pipeGroup) || check(this.brickGroup) || check(this.qblockGroup) || check(this.bounceGroup) || check(this.fireGroup);
}
/** Fill a gap with decorative water tiles. */
fillWater(gapX, gapW) {
const startY = GROUND_Y + BLOCK * 0.1;
const rows = Math.ceil((H - startY) / BLOCK) + 1;
// Place water tiles at the exact same grid positions where ground blocks were removed
for (let gx = Math.floor(gapX / BLOCK) * BLOCK; gx < gapX + gapW; gx += BLOCK) {
const cx = gx + BLOCK / 2;
// Only place if this position is inside the gap
if (!this.isInGap(cx))
continue;
for (let row = 0; row < rows; row++) {
const w = this.add.image(cx, startY + row * BLOCK + BLOCK / 2, 'water');
w.setDisplaySize(BLOCK, BLOCK);
w.setDepth(-1);
}
}
}
generateLevel(lo, hi) {
let x = Math.max(lo, this.genX);
let lastPattern = -1;
while (x < hi) {
// Varied spacing: mix short (2-3), medium (4-6), and occasional long (7-10) gaps
const spacingRoll = Math.random();
const spacing = spacingRoll < 0.3 ? (2 + Math.floor(Math.random() * 2))
: spacingRoll < 0.75 ? (4 + Math.floor(Math.random() * 3))
: (7 + Math.floor(Math.random() * 4));
x += spacing * BLOCK;
// Pick a pattern using shuffle-style selection (avoid repeating last pattern)
let pattern;
do {
pattern = Math.floor(Math.random() * 20);
} while (pattern === lastPattern);
lastPattern = pattern;
if (pattern === 0) {
// Coin arch — 5-6 coins in a parabolic arc
const arcLen = 5 + Math.floor(Math.random() * 2);
for (let i = 0; i < arcLen; i++) {
const t = i / (arcLen - 1);
const arcY = GROUND_Y - BLOCK * 1.5 - Math.sin(t * Math.PI) * BLOCK * 2;
const c = this.coinGroup.create(x + i * BLOCK + BLOCK / 2, arcY, 'coin0');
c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65);
c.body.setAllowGravity(false);
c.body.setSize(12, 18);
}
x += arcLen * BLOCK;
}
else if (pattern === 1) {
// Block row with ?-block
const n = 3 + Math.floor(Math.random() * 2);
const y = GROUND_Y - BLOCK * 2;
const qi = Math.floor(Math.random() * n);
for (let i = 0; i < n; i++) {
const bx = x + i * BLOCK;
if (i === qi) {
const q = this.qblockGroup.create(bx + BLOCK / 2, y + BLOCK / 2, 'qblock_img');
q.setData('hit', false);
q.setData('reward', 'coin');
q.setDisplaySize(BLOCK, BLOCK);
q.refreshBody();
}
else {
const b = this.brickGroup.create(bx + BLOCK / 2, y + BLOCK / 2, 'brown_block');
b.setDisplaySize(BLOCK, BLOCK);
b.refreshBody();
}
}
// Coins above block row
for (let i = 0; i < n; i++) {
if (Math.random() < 0.4) {
const c = this.coinGroup.create(x + i * BLOCK + BLOCK / 2, y - BLOCK / 2, 'coin0');
c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65);
c.body.setAllowGravity(false);
c.body.setSize(12, 18);
}
}
x += n * BLOCK;
// Enemy patrolling on top of the block row (~60% chance, ground types only)
if (Math.random() < 0.6) {
const enemyX = x - Math.floor(n / 2) * BLOCK;
const e = this.spawnEnemyAt('goomba', enemyX, y - BLOCK, true);
if (e) {
e.setVelocityX(0);
e.setData('patrolAwait', true);
e.setData('patrolLeft', enemyX - BLOCK * (n / 2 - 0.5));
e.setData('patrolRight', enemyX + BLOCK * (n / 2 - 0.5));
}
}
}
else if (pattern === 2) {
// Bounce pad — spring block that launches the player
const pad = this.bounceGroup.create(x + BLOCK / 2, GROUND_Y - BLOCK / 2, 'bounce_pad');
pad.setDisplaySize(BLOCK, BLOCK);
pad.refreshBody();
// Coins high above the pad as reward
for (let i = 0; i < 3; i++) {
const c = this.coinGroup.create(x + BLOCK / 2, GROUND_Y - BLOCK * (4 + i), 'coin0');
c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65);
c.body.setAllowGravity(false);
c.body.setSize(12, 18);
}
x += BLOCK * 2;
}
else if (pattern === 3) {
// Pipe — 1-2 blocks tall (jumpable)
const pipeBlocks = 1 + Math.floor(Math.random() * 2);
const ph = pipeBlocks * BLOCK;
const pw = 2 * BLOCK;
const py = GROUND_Y - ph;
const isGold = Math.random() < 0.25;
const isWarp = !isGold && Math.random() < 0.4;
let topSeg = null;
for (let yy = py; yy < GROUND_Y; yy += BLOCK) {
const isTop = yy === py;
const tex = isTop && isGold ? 'pipe_gold' : isTop && isWarp ? 'pipe_warp' : 'pipe_body';
const seg = this.pipeGroup.create(x + pw / 2, yy + BLOCK / 2, tex);
seg.setDisplaySize(BLOCK * 2, BLOCK);
seg.refreshBody();
if (isTop)
topSeg = seg;
}
if (topSeg) {
if (isWarp)
topSeg.setData('warp', true);
if (isGold)
topSeg.setData('gold', true);
}
// Piranha plant on regular pipes (~60% chance)
if (!isWarp && !isGold && Math.random() < 0.6) {
const p = this.piranhaGroup.create(x + pw / 2, py - 8, 'piranha_0');
p.setOrigin(0.5, 1);
p.setDisplaySize(BLOCK * 0.8, BLOCK);
p.body.setAllowGravity(false);
p.setData('pipeX', x + pw / 2);
p.setData('pipeTopY', py);
p.setData('timer', Math.random() * 4000);
p.setData('exposed', false);
p.setVisible(false);
}
x += pw;
}
else if (pattern === 4) {
// Enemy — single (combined enemy types, random pick)
const types = ['goomba', 'goomba', 'koopa', 'rkoopa'];
this.spawnEnemy(types[Math.floor(Math.random() * types.length)], x);
x += BLOCK * 2;
}
else if (pattern === 5) {
// Enemy pair — two different enemies spawned close together
const types = ['goomba', 'koopa', 'rkoopa'];
const t1 = types[Math.floor(Math.random() * types.length)];
let t2 = types[Math.floor(Math.random() * types.length)];
while (t2 === t1)
t2 = types[Math.floor(Math.random() * types.length)];
this.spawnEnemy(t1, x);
this.spawnEnemy(t2, x + BLOCK * 2);
x += BLOCK * 4;
}
else if (pattern === 6) {
// Combined ?-block — mushroom or coin reward
const reward = Math.random() < 0.35 ? 'mushroom' : 'coin';
const q = this.qblockGroup.create(x + BLOCK / 2, GROUND_Y - BLOCK * 2 + BLOCK / 2, 'qblock_img');
q.setData('hit', false);
q.setData('reward', reward);
q.setDisplaySize(BLOCK, BLOCK);
q.refreshBody();
x += BLOCK;
}
else if (pattern === 7) {
// Ascending staircase with enemy on top (max 3 steps for reachability)
const h = 2 + Math.floor(Math.random() * 2);
for (let step = 0; step < h; step++) {
const b = this.brickGroup.create(x + step * BLOCK + BLOCK / 2, GROUND_Y - (step + 1) * BLOCK + BLOCK / 2, 'brown_block');
b.setDisplaySize(BLOCK, BLOCK);
b.refreshBody();
}
// 50% chance enemy on the top step
if (Math.random() < 0.5) {
const topX = x + (h - 1) * BLOCK + BLOCK / 2;
const topY = GROUND_Y - h * BLOCK - BLOCK;
this.spawnEnemyAt('goomba', topX, topY);
}
x += h * BLOCK;
}
else if (pattern === 8) {
// Water gap
const gapW = (3 + Math.floor(Math.random() * 2)) * BLOCK;
this.gaps.push({ start: x, end: x + gapW });
this.groundGroup.getChildren().forEach((g) => {
if (g.x >= x && g.x < x + gapW)
g.destroy();
});
this.fillWater(x, gapW);
// 50% chance: fire eruption hazard in the gap
if (Math.random() < 0.5) {
const fireX = x + gapW / 2;
const f = this.fireGroup.create(fireX, GROUND_Y + BLOCK * 2, 'fire_column');
f.setDisplaySize(BLOCK * 0.8, BLOCK * 2);
f.setOrigin(0.5, 1);
f.body.setAllowGravity(false);
f.setData('baseY', GROUND_Y + BLOCK * 2);
f.setData('gapX', fireX);
f.setData('active', false);
f.setVisible(false);
f.body.enable = false;
}
x += gapW;
}
else if (pattern === 9) {
// Collapsing bridge over gap
const bridgeLen = 4 + Math.floor(Math.random() * 4);
const gapW = bridgeLen * BLOCK;
this.gaps.push({ start: x, end: x + gapW });
this.groundGroup.getChildren().forEach((g) => {
if (g.x >= x && g.x < x + gapW)
g.destroy();
});
this.fillWater(x, gapW);
// Decide which tiles are unstable: first & last always stable,
// never two consecutive unstable, max ~40% unstable
const unstableMap = new Array(bridgeLen).fill(false);
const maxUnstable = Math.floor(bridgeLen * 0.4);
let unstableCount = 0;
for (let i = 1; i < bridgeLen - 1; i++) {
if (unstableCount >= maxUnstable)
break;
if (unstableMap[i - 1])
continue; // previous was unstable, skip
if (Math.random() < 0.35) {
unstableMap[i] = true;
unstableCount++;
}
}
for (let i = 0; i < bridgeLen; i++) {
const bx = x + i * BLOCK + BLOCK / 2;
const bt = this.bridgeGroup.create(bx, GROUND_Y + BLOCK / 2, 'bridge_tile');
bt.setDisplaySize(BLOCK, BLOCK);
bt.refreshBody();
bt.setData('unstable', unstableMap[i]);
bt.setData('collapsing', false);
// Spawn a fish under each unstable tile
if (unstableMap[i]) {
const fish = this.fishGroup.create(bx, GROUND_Y + BLOCK * 2, 'fish');
fish.setOrigin(0.5, 0.5);
fish.body.setAllowGravity(false);
fish.setVisible(false);
fish.body.enable = false;
fish.setData('homeX', bx);
fish.setData('jumped', false);
}
}
x += gapW;
}
else if (pattern === 10) {
// Multi-tier platform — 2 levels with room to run
const lowerY = GROUND_Y - BLOCK * 2;
for (let i = 0; i < 4; i++) {
if (i === 1) {
const q = this.qblockGroup.create(x + i * BLOCK + BLOCK / 2, lowerY + BLOCK / 2, 'qblock_img');
q.setData('hit', false);
q.setData('reward', 'coin');
q.setDisplaySize(BLOCK, BLOCK);
q.refreshBody();
}
else {
const b = this.brickGroup.create(x + i * BLOCK + BLOCK / 2, lowerY + BLOCK / 2, 'brown_block');
b.setDisplaySize(BLOCK, BLOCK);
b.refreshBody();
}
}
const upperY = GROUND_Y - BLOCK * 5.5;
for (let i = 1; i <= 2; i++) {
const b = this.brickGroup.create(x + i * BLOCK + BLOCK / 2, upperY + BLOCK / 2, 'brown_block');
b.setDisplaySize(BLOCK, BLOCK);
b.refreshBody();
}
const c = this.coinGroup.create(x + 1.5 * BLOCK + BLOCK / 2, upperY - BLOCK / 2, 'coin0');
c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65);
c.body.setAllowGravity(false);
c.body.setSize(12, 18);
x += 4 * BLOCK;
}
else if (pattern === 11) {
// Mixed brick/qblock cluster with enemy
const clusterLen = 5;
const clusterY = GROUND_Y - BLOCK * 2;
const qPositions = new Set();
const qCount = 1 + Math.floor(Math.random() * 2);
while (qPositions.size < qCount) {
qPositions.add(Math.floor(Math.random() * clusterLen));
}
for (let i = 0; i < clusterLen; i++) {
if (qPositions.has(i)) {
const q = this.qblockGroup.create(x + i * BLOCK + BLOCK / 2, clusterY + BLOCK / 2, 'qblock_img');
q.setData('hit', false);
q.setData('reward', 'coin');
q.setDisplaySize(BLOCK, BLOCK);
q.refreshBody();
}
else {
const b = this.brickGroup.create(x + i * BLOCK + BLOCK / 2, clusterY + BLOCK / 2, 'brown_block');
b.setDisplaySize(BLOCK, BLOCK);
b.refreshBody();
}
}
x += clusterLen * BLOCK;
// Enemy on top of the cluster
if (Math.random() < 0.5) {
this.spawnEnemyAt('goomba', x - 2 * BLOCK, clusterY - BLOCK);
}
}
else if (pattern === 12) {
// Elevated bridge with enemy
const bridgeLen = 4 + Math.floor(Math.random() * 3);
const bridgeY = GROUND_Y - BLOCK * 2;
for (let i = 0; i < bridgeLen; i++) {
const b = this.brickGroup.create(x + i * BLOCK + BLOCK / 2, bridgeY + BLOCK / 2, 'brown_block');
b.setDisplaySize(BLOCK, BLOCK);
b.refreshBody();
}
// Coins along the bridge
for (let i = 0; i < bridgeLen; i += 2) {
const c = this.coinGroup.create(x + i * BLOCK + BLOCK / 2, bridgeY - BLOCK / 2, 'coin0');
c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65);
c.body.setAllowGravity(false);
c.body.setSize(12, 18);
}
this.spawnEnemyAt('goomba', x + BLOCK, bridgeY - BLOCK);
x += bridgeLen * BLOCK;
}
else if (pattern === 13) {
// Descending staircase
const h = 2 + Math.floor(Math.random() * 2);
for (let step = 0; step < h; step++) {
const b = this.brickGroup.create(x + step * BLOCK + BLOCK / 2, GROUND_Y - (h - step) * BLOCK + BLOCK / 2, 'brown_block');
b.setDisplaySize(BLOCK, BLOCK);
b.refreshBody();
}
// Enemy on top
if (Math.random() < 0.4) {
this.spawnEnemyAt('goomba', x + BLOCK / 2, GROUND_Y - h * BLOCK - BLOCK);
}
x += h * BLOCK;
}
else if (pattern === 14) {
// Floating coins — zigzag pattern
const zigLen = 4 + Math.floor(Math.random() * 2);
for (let i = 0; i < zigLen; i++) {
const zigY = GROUND_Y - BLOCK * 2 - (i % 2 === 0 ? 0 : BLOCK);
const c = this.coinGroup.create(x + i * BLOCK + BLOCK / 2, zigY, 'coin0');
c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65);
c.body.setAllowGravity(false);
c.body.setSize(12, 18);
}
x += zigLen * BLOCK;
}
else if (pattern === 15) {
// Spike gauntlet — spike, platform, spike, platform pattern
const pairs = 2 + Math.floor(Math.random() * 2); // 2-3 spike-platform pairs
const spacing = BLOCK * 1.6;
for (let i = 0; i < pairs * 2 + 1; i++) {
const sx = x + i * spacing;
if (i % 2 === 0) {
// Spike
const spike = this.add.image(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.3, 'spikes_tile');
spike.setDisplaySize(BLOCK, BLOCK * 0.6);
spike.setDepth(2);
const hitZone = this.fireGroup.create(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.2, 'spikes_tile');
hitZone.setDisplaySize(BLOCK * 0.9, BLOCK * 0.4);
hitZone.setAlpha(0);
hitZone.body.setAllowGravity(false);
hitZone.body.enable = true;
// Warm glow behind spikes (Ellipse Shape — WebGL batched)
const spikeGlow = this.add.ellipse(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.4, BLOCK * 1.8, BLOCK * 1.6, 0xff4400, 1.0);
spikeGlow.setDepth(1);
spikeGlow.setAlpha(0.2);
spikeGlow.setBlendMode(Phaser.BlendModes.ADD);
hitZone.setData('manualGlow', spikeGlow);
hitZone.setData('hasGlow', true);
// Sparks that shoot UP high above the spikes
const sparks = this.add.particles(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.6, 'coin0', {
speed: { min: 40, max: 100 },
angle: { min: 250, max: 290 },
scale: { start: 0.2, end: 0 },
alpha: { start: 0.9, end: 0 },
lifespan: { min: 500, max: 1200 },
frequency: 120,
quantity: 2,
tint: [0xff2200, 0xff4400, 0xff6600, 0xffaa00, 0xffff00],
blendMode: 'ADD',
gravityY: 30,
});
sparks.setDepth(3);
hitZone.setData('sparks', sparks);
}
else {
// Small raised platform to land on between spikes
const plat = this.groundGroup.create(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.5 + BLOCK / 2, 'grass_block');
plat.setDisplaySize(BLOCK, BLOCK);
plat.refreshBody();
}
}
x += (pairs * 2 + 1) * spacing;
}
else if (pattern === 16) {
// Staircase up — 3 tiers ascending, enough clearance to run on each
for (let tier = 0; tier < 3; tier++) {
const tierY = GROUND_Y - BLOCK * (2 + tier * 3);
const tierX = x + tier * BLOCK * 2.5;
const width = 4 - tier; // wider at bottom
for (let i = 0; i < width; i++) {
// Top tier, first block → ?-block with reward
if (tier === 2 && i === 0) {
const q = this.qblockGroup.create(tierX + i * BLOCK + BLOCK / 2, tierY + BLOCK / 2, 'qblock_img');
q.setData('hit', false);
q.setData('reward', Math.random() < 0.4 ? 'mushroom' : 'coin');
q.setDisplaySize(BLOCK, BLOCK);
q.refreshBody();
}
else {
const b = this.brickGroup.create(tierX + i * BLOCK + BLOCK / 2, tierY + BLOCK / 2, 'brown_block');
b.setDisplaySize(BLOCK, BLOCK);
b.refreshBody();
}
}
// Coin on each tier
const c = this.coinGroup.create(tierX + BLOCK / 2, tierY - BLOCK / 2, 'coin0');
c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65);
c.body.setAllowGravity(false);
c.body.setSize(12, 18);
}
x += 8 * BLOCK;
}
else if (pattern === 17) {
// Tower — 3-high column with platforms branching off, room to run
const towerX = x + BLOCK * 3;
for (let level = 0; level < 3; level++) {
const ly = GROUND_Y - BLOCK * (2 + level * 3);
// Central column block
const b = this.brickGroup.create(towerX + BLOCK / 2, ly + BLOCK / 2, 'brown_block');
b.setDisplaySize(BLOCK, BLOCK);
b.refreshBody();
// Side platform (alternating left/right)
const side = level % 2 === 0 ? -1 : 1;
for (let s = 1; s <= 2; s++) {
const sb = this.brickGroup.create(towerX + side * s * BLOCK + BLOCK / 2, ly + BLOCK / 2, 'brown_block');
sb.setDisplaySize(BLOCK, BLOCK);
sb.refreshBody();
}
// Coin on the platform
const c = this.coinGroup.create(towerX + side * BLOCK + BLOCK / 2, ly - BLOCK / 2, 'coin0');
c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65);
c.body.setAllowGravity(false);
c.body.setSize(12, 18);
}
x += 8 * BLOCK;
}
else if (pattern === 18) {
// Pyramid — wide base narrowing up, 3 levels with running room
const baseW = 5;
for (let level = 0; level < 3; level++) {
const ly = GROUND_Y - BLOCK * (2 + level * 3);
const lw = baseW - level * 2;
const lx = x + level * BLOCK;
for (let i = 0; i < lw; i++) {
const isQ = level === 2 && i === Math.floor(lw / 2);
if (isQ) {
const q = this.qblockGroup.create(lx + i * BLOCK + BLOCK / 2, ly + BLOCK / 2, 'qblock_img');
q.setData('hit', false);
q.setData('reward', Math.random() < 0.3 ? 'mushroom' : 'coin');
q.setDisplaySize(BLOCK, BLOCK);
q.refreshBody();
}
else {
const b = this.brickGroup.create(lx + i * BLOCK + BLOCK / 2, ly + BLOCK / 2, 'brown_block');
b.setDisplaySize(BLOCK, BLOCK);
b.refreshBody();
}
}
}
// Enemy patrolling the base
if (Math.random() < 0.5) {
this.spawnEnemyAt('goomba', x + BLOCK, GROUND_Y - BLOCK);
}
x += (baseW + 1) * BLOCK;
}
else if (pattern === 19) {
// Small croc pond — narrow water gap with 1-2 crocodiles
const gapW = (2 + Math.floor(Math.random() * 2)) * BLOCK; // 2-3 blocks wide
this.gaps.push({ start: x, end: x + gapW });
this.groundGroup.getChildren().forEach((g) => {
if (g.x >= x && g.x < x + gapW)
g.destroy();
});
this.fillWater(x, gapW);
const numCrocs = gapW >= BLOCK * 3 ? 2 : 1;
const spacing = gapW / (numCrocs + 1);
for (let ci = 0; ci < numCrocs; ci++) {
const cx = x + spacing * (ci + 1);
const cy = GROUND_Y + 8; // Sit on water surface
const croc = this.crocGroup.create(cx, cy, 'croc_closed');
croc.setOrigin(0.5, 1);
croc.body.setAllowGravity(false);
croc.body.setSize(58, 16);
croc.setData('mouthOpen', false);
croc.setData('timer', this.time.now + 2000 + Math.random() * 2000);
croc.setData('gapStart', x);
croc.setData('gapEnd', x + gapW);
croc.setData('swimDir', Math.random() < 0.5 ? 1 : -1);
croc.setVelocityX(croc.getData('swimDir') * 30);
}
x += gapW;
}
}
this.genX = Math.max(this.genX, x);
this.extendGround(0, this.genX + W);
// Scatter decorative bushes in the new section
for (let bx = lo; bx < hi; bx += BLOCK * 6 + Math.floor(Math.random() * BLOCK * 4)) {
if (this.isInGap(bx))
continue;
const isBig = Math.random() < 0.3;
const tex = isBig ? 'big_bush' : 'small_bush';
const bush = this.add.image(bx, GROUND_Y, tex);
bush.setDisplaySize(isBig ? BLOCK * 1.5 : BLOCK, BLOCK * 0.5);
bush.setOrigin(0.5, 1);
bush.setDepth(1);
bush.setAlpha(0.8);
}
// Scatter ground-level coin trails between obstacles (skip coins near pipes)
for (let cx = lo; cx < hi; cx += BLOCK * 8 + Math.floor(Math.random() * BLOCK * 6)) {
if (this.isInGap(cx))
continue;
if (Math.random() < 0.4)
continue; // skip some
const trailLen = 2 + Math.floor(Math.random() * 3);
for (let i = 0; i < trailLen; i++) {
const coinX = cx + i * BLOCK;
if (this.isInGap(coinX))
break;
if (this.isNearObstacle(coinX))
break;
const c = this.coinGroup.create(coinX + BLOCK / 2, GROUND_Y - BLOCK * 0.7, 'coin0');
c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65);
c.body.setAllowGravity(false);
c.body.setSize(12, 18);
}
}
// Rare heart pickup — extra life, hard to reach (2% chance per section)
if (Math.random() < 0.02) {
// Place high above a random non-gap spot — needs bounce pad or double-jump
const hx = lo + Math.floor(Math.random() * (hi - lo - BLOCK * 4)) + BLOCK * 2;
if (!this.isInGap(hx)) {
const hy = GROUND_Y - BLOCK * (3 + Math.random() * 1.5); // high but reachable with a good jump
const h = this.heartGroup.create(hx, hy, 'heart_anim', 0);
h.setDisplaySize(BLOCK * 0.7, BLOCK * 0.7);
h.body.setAllowGravity(false);
h.anims.play('heart_pulse', true);
// Subtle float animation
this.tweens.add({
targets: h,
y: hy - BLOCK * 0.3,
duration: 1200,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
}
// Flag checkpoint every ~2500px
this.distanceSinceFlag += (hi - lo);
if (this.distanceSinceFlag > 2500) {
this.distanceSinceFlag = 0;
const flagX = this.genX - BLOCK * 2;
if (!this.isInGap(flagX)) {
for (let i = 0; i < 3; i++) {
const pole = this.add.image(flagX, GROUND_Y - i * BLOCK - BLOCK / 2, 'brown_block');
pole.setDisplaySize(BLOCK * 0.3, BLOCK);
pole.setDepth(1);
}
const flag = this.flagGroup.create(flagX, GROUND_Y - BLOCK * 3 + BLOCK / 2, 'flag_tile');
flag.setDisplaySize(BLOCK, BLOCK * 1.5);
flag.setOrigin(0.5, 1);
flag.refreshBody();
}
}
}
spawnEnemy(kind, x) {
this.spawnEnemyAt(kind, x + BLOCK / 2, GROUND_Y);
}
spawnEnemyAt(kind, x, y, groundOnly = false) {
let roll = Math.random();
// When spawning on blocks, re-roll if we get a bat (bats fly away)
if (groundOnly && roll >= 0.80)
roll = Math.random() * 0.80;
let tex;
let animKey;
let enemyType;
let speed;
let displayH = BLOCK;
if (roll < 0.30) {
tex = 'enemy';
animKey = 'enemy_walk';
enemyType = 'monster';
speed = -100;
}
else if (roll < 0.55) {
tex = 'enemy_short';
animKey = 'enemy_short_walk';
enemyType = 'bulldog';
speed = -140;
}
else if (roll < 0.80) {
tex = 'enemy_tall';
animKey = 'enemy_tall_walk';
enemyType = 'snake';
speed = -80;
displayH = BLOCK * 1.5;
}
else {
tex = 'bat_0';
animKey = '';
enemyType = 'bat';
speed = -120;
}
const isBat = enemyType === 'bat';
const e = this.enemyGroup.create(x, y, tex, 0);
e.setOrigin(0.5, 1);
e.setDisplaySize(BLOCK, displayH);
e.body.setGravityY(isBat ? 0 : 1800);
e.body.setAllowGravity(!isBat);
e.setVelocityX(speed);
e.setBounceX(1);
e.setCollideWorldBounds(false);
e.setData('kind', kind);
e.setData('enemyType', enemyType);
e.setData('state', 'walk');
e.setData('timer', 0);
e.setData('baseY', y);
if (animKey) {
e.anims.play(animKey, true);
}
// Tighten hitbox for tall enemies (snake) — default body is too wide
if (enemyType === 'snake') {
e.body.setSize(10, 28);
e.body.setOffset(3, 4);
}
return e;
}
update(_t, dtMs) {
if (this.dead) {
this.deadTimer -= dtMs;
this.player.setVelocityX(0);
if (this.deadTimer <= 0 && !this.gameOverShown)
this.respawn();
return;
}
if (this.warping)
return;
if (this.parachuteMode) {
this.updateParachute(dtMs);
return;
}
if (this.invincible > 0)
this.invincible -= dtMs;
if (this.shrinkTimer > 0)
this.shrinkTimer -= dtMs;
if (this.stompGrace > 0)
this.stompGrace -= dtMs;
if (this.fireCooldown > 0)
this.fireCooldown -= dtMs;
this.updatePlayerMovement(dtMs);
if (this.player.y > H + 50) {
this.die();
return;
}
const edge = this.cameras.main.scrollX + W + 600;
if (edge > this.genX)
this.generateLevel(this.genX, edge);
this.updatePlayerAnimation();
const camLeft = this.cameras.main.scrollX;
this.updateCoins(camLeft);
this.updateBridges(camLeft);
this.updateFireEruptions(camLeft);
this.updatePiranhas(dtMs, camLeft);
this.enemyGroup.getChildren().forEach(e => this.updateEnemy(e, camLeft));
this.updateCrocs(camLeft);
this.cleanupOffscreen(camLeft);
}
updateParachute(dtMs) {
// Stop camera from following — terrain stays fixed
this.cameras.main.stopFollow();
if (this.parachuteSprite) {
this.parachuteSprite.x = this.player.x;
const playerH = PLAYER_H;
this.parachuteSprite.y = this.player.y - playerH + 8;
}
// Full directional control with arrow keys
const camX = this.cameras.main.scrollX;
if (this.cursors.left.isDown) {
this.player.setVelocityX(-200);
}
else if (this.cursors.right.isDown) {
this.player.setVelocityX(200);
}
else {
this.player.setVelocityX(Math.sin(this.time.now / 1200) * 40);
}
if (this.cursors.up.isDown) {
this.player.setVelocityY(-180);
}
else if (this.cursors.down.isDown) {
this.player.setVelocityY(300);
}
// Keep Player within visible screen
if (this.player.x < camX + 30)
this.player.x = camX + 30;
if (this.player.x > camX + W - 30)
this.player.x = camX + W - 30;
if (this.player.y < 40)
this.player.y = 40;
this.parachuteTimer += dtMs;
if (this.parachuteTimer > 1500 && this.parachuteFlyingEnemies.length < 15) {
this.parachuteTimer = 0;
const fromLeft = Math.random() < 0.5;
const camX = this.cameras.main.scrollX;
const ex = fromLeft ? camX - 20 : camX + W + 20;
const ey = this.player.y + (Math.random() - 0.3) * 200;
const fe = this.enemyGroup.create(ex, ey, 'enemy', 0);
fe.setOrigin(0.5, 0.5);
fe.setDisplaySize(BLOCK, BLOCK);
fe.body.setAllowGravity(false);
fe.setVelocityX(fromLeft ? 150 : -150);
fe.setData('kind', 'goomba');
fe.setData('state', 'flying');
fe.setData('timer', 0);
this.parachuteFlyingEnemies.push(fe);
}
const pCamLeft = this.cameras.main.scrollX;
this.parachuteFlyingEnemies = this.parachuteFlyingEnemies.filter(e => {
if (!e.active)
return false;
if (e.x < pCamLeft - 100 || e.x > pCamLeft + W + 100) {
e.destroy();
return false;
}
return true;
});
const pOnGround = this.player.body.blocked.down;
const falling = this.player.body.velocity.y >= 0;
if (pOnGround && falling && !this.cursors.up.isDown) {
this.endParachute();
}
// Die if player drifts into water/gap below ground level
if (this.player.y > GROUND_Y + BLOCK) {
this.endParachute();
this.die();
return;
}
this.player.anims.stop();
this.player.setFrame(4); // jump frame while parachuting
if (this.cursors.left.isDown)
this.player.flipX = true;
else if (this.cursors.right.isDown)
this.player.flipX = false;
this.player.setDisplaySize(PLAYER_W, PLAYER_H);
// Check enemy collisions during parachute
this.enemyGroup.getChildren().forEach((e) => {
if (!e.active || e.getData('state') === 'dead')
return;
const dx = Math.abs(this.player.x - e.x);
const dy = this.player.y - e.y;
if (dx < BLOCK * 0.8 && Math.abs(dy) < BLOCK * 0.8) {
if (dy < 0) {
// Player is above enemy — stomp kill
e.setVelocityY(-300);
e.flipY = true;
e.setData('state', 'dead');
this.time.delayedCall(600, () => { if (e.active)
e.destroy(); });
this.addScore(300, e.x, e.y - 10);
}
else if (this.invincible <= 0) {
// Enemy hit player from side/below — lose a life
this.endParachute();
this.die();
}
}
});
this.syncScoreToHUD();
return;
}
updatePlayerMovement(dtMs) {
const running = this.keys.shift.isDown;
// Powered up = faster speed + higher acceleration
const speedMult = this.isBig ? 1.5 : 1;
const maxSpeed = (running ? 320 : 200) * speedMult;
const accel = (running ? 1100 : 800) * speedMult;
if (this.cursors.left.isDown) {
this.player.setAccelerationX(-accel);
this.facingRight = false;
if (this.player.body.velocity.x > 0)
this.player.setVelocityX(this.player.body.velocity.x * 0.7);
}
else if (this.cursors.right.isDown) {
this.player.setAccelerationX(accel);
this.facingRight = true;
if (this.player.body.velocity.x < 0)
this.player.setVelocityX(this.player.body.velocity.x * 0.7);
}
else {
this.player.setAccelerationX(0);
const v = this.player.body.velocity.x;
if (Math.abs(v) < 12)
this.player.setVelocityX(0);
else
this.player.setVelocityX(v * 0.9);
}
if (this.player.body.velocity.x > maxSpeed)
this.player.setVelocityX(maxSpeed);
if (this.player.body.velocity.x < -maxSpeed)
this.player.setVelocityX(-maxSpeed);
const onGround = this.player.body.blocked.down || this.player.body.touching.down;
const touchingWall = this.player.body.blocked.left || this.player.body.blocked.right;
if (onGround) {
this.coyoteTime = 120; // generous coyote time, especially helps near walls
this.hasDoubleJumped = false;
this.canDoubleJump = false;
}
else {
this.coyoteTime = Math.max(0, this.coyoteTime - dtMs);
if (!this.canDoubleJump && this.coyoteTime <= 0)
this.canDoubleJump = true;
}
this.jumpBuffer = Math.max(0, this.jumpBuffer - dtMs);
const jumpKeyDown = this.keys.space.isDown || this.cursors.up.isDown;
const jumpJustPressed = jumpKeyDown && !this.jumpKeyWasDown;
this.jumpKeyWasDown = jumpKeyDown;
if (jumpJustPressed)
this.jumpBuffer = 150;
// Allow jump when on ground OR when pressed against a wall and recently on ground
const canJump = this.coyoteTime > 0 || (touchingWall && this.coyoteTime > -50);
if (this.jumpBuffer > 0 && canJump) {
// Normal jump — higher when powered up
this.player.setVelocityY(this.isBig ? -950 : -820);
this.jumpBuffer = 0;
this.coyoteTime = 0;
this.sfx('nr_jump', 0.2);
}
else if (jumpJustPressed && !onGround && this.canDoubleJump && !this.hasDoubleJumped) {
// Double jump — also boosted when powered
this.player.setVelocityY(this.isBig ? -800 : -700);
this.hasDoubleJumped = true;
this.sfx('nr_jump', 0.15);
}
// Variable jump height: low gravity while ascending and key held.
if (jumpKeyDown && this.player.body.velocity.y < 0) {
this.player.body.setGravityY(900);
}
else {
this.player.body.setGravityY(1800);
}
if (this.isBig && this.fireCooldown <= 0 &&
(Phaser.Input.Keyboard.JustDown(this.keys.f) || Phaser.Input.Keyboard.JustDown(this.keys.z))) {
this.throwFireball();
this.fireCooldown = 200;
}
const camLeft = this.cameras.main.scrollX;
if (this.player.x < camLeft) {
this.player.x = camLeft;
this.player.setVelocityX(0);
}
if (onGround && !this.isInGap(this.player.x)) {
this.lastSafeX = this.player.x;
}
// Warp / golden pipe check — Player must be standing ON TOP of the pipe
if (onGround && this.cursors.down.isDown && !this.warping) {
const pipes = this.pipeGroup.getChildren();
for (const p of pipes) {
if (!p.getData('warp') && !p.getData('gold'))
continue;
const pdx = Math.abs(this.player.x - p.x);
// Player's feet (y with origin 0.5,1) should be at the pipe top edge
const pipeTop = p.y - BLOCK / 2;
const feetDelta = Math.abs(this.player.y - pipeTop);
// Also accept Player standing at ground level next to a short pipe
if (pdx < BLOCK * 1.5 && feetDelta < BLOCK) {
if (p.getData('gold') && !this.parachuteMode) {
this.startParachute(p);
}
else if (p.getData('warp')) {
this.startWarp(p);
}
break;
}
}
}
}
updatePlayerAnimation() {
const onGround = this.player.body.blocked.down || this.player.body.touching.down;
const vx = this.player.body.velocity.x;
const speed = Math.abs(vx);
// Player-intent direction this frame (from input). Used to detect skid.
const left = this.cursors.left.isDown;
const right = this.cursors.right.isDown;
const intent = right ? 1 : left ? -1 : 0;
const moveDir = vx > 5 ? 1 : vx < -5 ? -1 : 0;
// Animation: use Phaser's anims system with the spritesheet.
const sheetKey = 'player';
const walkAnim = 'player_walk';
// Scale — always same size, glow indicates power-up
this.player.setDisplaySize(PLAYER_W, PLAYER_H);
// Pulse the built-in glow FX when powered up
if (this.player.getData('hasGlow')) {
const glowFx = this.player.getData('glowFx');
if (glowFx) {
glowFx.outerStrength = this.isBig ? 2 + Math.sin(this.time.now / 200) * 1.5 : 0;
}
}
// Ensure correct texture
if (this.player.texture.key !== sheetKey) {
this.player.setTexture(sheetKey, 0);
}
if (!onGround) {
this.player.anims.stop();
this.player.setFrame(4); // jump
}
else if (intent !== 0 && moveDir !== 0 && intent !== moveDir && speed > 60) {
this.player.anims.stop();
this.player.setFrame(0); // no skid frame in new set, use idle
}
else if (speed > 20) {
this.player.anims.play(walkAnim, true);
const animFps = Math.max(6, Math.min(20, speed / 20));
this.player.anims.msPerFrame = 1000 / animFps;
}
else {
this.player.anims.stop();
this.player.setFrame(0); // idle
}
// Face the input direction while skidding (so skid sprite looks "back"
// toward old motion); otherwise face current motion / last facing.
if (intent !== 0)
this.facingRight = intent > 0;
else if (moveDir !== 0)
this.facingRight = moveDir > 0;
this.player.flipX = !this.facingRight;
const blink = (this.invincible > 0 || this.shrinkTimer > 0) && Math.floor(this.time.now / 80) % 2 === 0;
this.player.setVisible(!blink);
// Track player glow (mushroom powerup) — centered on player body, not feet
const playerGlow = this.player.getData('glowFx');
if (playerGlow) {
if (this.isBig && this.player.visible) {
playerGlow.setPosition(this.player.x, this.player.y - PLAYER_H / 2);
playerGlow.setAlpha(0.15 + Math.sin(this.time.now / 100) * 0.1);
playerGlow.setVisible(true);
}
else if (!this.isBig) {
playerGlow.destroy();
this.player.setData('glowFx', null);
this.player.setData('hasGlow', false);
}
else {
playerGlow.setVisible(false);
}
}
}
updateCoins(camLeft) {
this.coinGroup.getChildren().forEach(c => {
const i = Math.floor(this.time.now / 120) % 2;
c.setTexture(i === 0 ? 'coin0' : 'coin1');
if (c.x < camLeft - 100)
c.destroy();
});
}
updateBridges(camLeft) {
// Bridge collapse — unstable tiles start falling when player approaches
this.bridgeGroup.getChildren().forEach((bt) => {
if (!bt.active || !bt.getData('unstable') || bt.getData('collapsing'))
return;
const dx = bt.x - this.player.x;
// Trigger when player is within 6 blocks ahead or 2 blocks behind
if (dx < BLOCK * 6 && dx > -BLOCK * 2) {
bt.setData('collapsing', true);
const tileX = bt.x;
// ~1 second shake warning before falling
this.tweens.add({
targets: bt,
x: bt.x + 3,
duration: 60,
yoyo: true,
repeat: 8,
onComplete: () => {
bt.body.enable = false;
this.tweens.add({
targets: bt,
y: bt.y + 300,
alpha: 0,
duration: 500,
onComplete: () => bt.destroy(),
});
// Launch fish from the gap where tile fell
this.fishGroup.getChildren().forEach((fish) => {
if (!fish.active || fish.getData('jumped'))
return;
if (Math.abs(fish.getData('homeX') - tileX) < BLOCK) {
fish.setData('jumped', true);
fish.setVisible(true);
fish.body.enable = true;
fish.setPosition(tileX, GROUND_Y + BLOCK);
// Arc jump: up just above bridge level, then back down
this.tweens.add({
targets: fish,
y: GROUND_Y - BLOCK * 0.8,
duration: 400,
ease: 'Sine.easeOut',
onComplete: () => {
this.tweens.add({
targets: fish,
y: GROUND_Y + BLOCK * 2,
duration: 400,
ease: 'Sine.easeIn',
onComplete: () => {
fish.body.enable = false;
fish.setVisible(false);
// Reset for possible re-jump after a delay
this.time.delayedCall(1500 + Math.random() * 2000, () => {
if (fish.active) {
fish.setData('jumped', false);
}
});
},
});
},
});
}
});
},
});
}
});
}
updateFireEruptions(camLeft) {
// Fire eruptions — shoot up from gaps when player approaches
// Warning glow/smoke appears first; fire ONLY erupts after warning has been
// visible for a minimum duration so the player always gets fair notice.
const WARN_MIN_MS = 800; // warning must show for at least this long before fire
this.fireGroup.getChildren().forEach((f) => {
if (!f.active)
return;
const dx = Math.abs(this.player.x - f.getData('gapX'));
const baseY = f.getData('baseY');
const isActive = f.getData('active');
const isWarning = f.getData('warning');
// Warning phase — show smoke/glow when player is within 10 blocks
if (dx < BLOCK * 10 && !isActive && !isWarning) {
f.setData('warning', true);
f.setData('warnStart', this.time.now);
// Rising smoke/ember particles
const warnEmbers = this.add.particles(f.getData('gapX'), GROUND_Y, 'coin0', {
speed: { min: 20, max: 60 },
angle: { min: 255, max: 285 },
scale: { start: 0.2, end: 0 },
alpha: { start: 0.5, end: 0 },
lifespan: { min: 400, max: 800 },
frequency: 40,
quantity: 2,
tint: [0xff4400, 0xff6600, 0x888888, 0x666666],
blendMode: 'ADD',
});
warnEmbers.setDepth(f.depth + 1);
f.setData('warnEmbers', warnEmbers);
// Pulsing orange glow at gap base
const warnGlow = this.add.ellipse(f.getData('gapX'), GROUND_Y + BLOCK * 0.5, BLOCK * 2, BLOCK * 1.5, 0xff4400, 1.0);
warnGlow.setAlpha(0);
warnGlow.setBlendMode(Phaser.BlendModes.ADD);
warnGlow.setDepth(f.depth - 1);
f.setData('warnGlow', warnGlow);
this.tweens.add({
targets: warnGlow,
alpha: { from: 0, to: 0.35 },
duration: 350,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
}
// Fire only erupts after warning has been visible long enough
const warnStart = f.getData('warnStart') || 0;
const warnElapsed = this.time.now - warnStart;
if (dx < BLOCK * 4 && !isActive && isWarning && warnElapsed >= WARN_MIN_MS) {
// Erupt!
f.setData('active', true);
// Clean up warning effects
const we = f.getData('warnEmbers');
if (we) {
we.stop();
this.time.delayedCall(800, () => { if (we)
we.destroy(); });
}
const wg = f.getData('warnGlow');
if (wg) {
this.tweens.killTweensOf(wg);
wg.destroy();
}
f.setData('warnEmbers', null);
f.setData('warnGlow', null);
f.setData('warning', false);
f.setVisible(true);
f.body.enable = true;
f.y = baseY;
this.tweens.add({
targets: f,
y: GROUND_Y - BLOCK * 2,
duration: 300,
ease: 'Quad.easeOut',
onComplete: () => {
// Hold briefly then retract
this.time.delayedCall(800, () => {
if (!f.active)
return;
this.tweens.add({
targets: f,
y: baseY,
duration: 400,
onComplete: () => {
f.setVisible(false);
f.body.enable = false;
// Reset after cooldown
this.time.delayedCall(2000, () => {
if (f.active)
f.setData('active', false);
});
},
});
});
},
});
}
// Flicker effect while visible + full fire FX
const fireVisible = f.visible && f.alpha > 0;
if (fireVisible) {
f.setAlpha(0.8 + Math.sin(this.time.now / 50) * 0.2);
if (!f.getData('hasGlow')) {
// Tall glow column from fire down to bottom of scene — additive blend
const glowH = H - f.y + BLOCK * 2;
const columnGlow = this.add.ellipse(f.x, f.y + glowH / 2, BLOCK * 1.4, glowH, 0xff4400, 1.0);
columnGlow.setDepth(f.depth - 1);
columnGlow.setAlpha(0.15);
columnGlow.setBlendMode(Phaser.BlendModes.ADD);
f.setData('hasGlow', true);
f.setData('manualGlow', columnGlow);
// Rising ember particles
const embers = this.add.particles(f.x, f.y, 'coin0', {
speed: { min: 15, max: 50 },
angle: { min: 250, max: 290 },
scale: { start: 0.15, end: 0 },
alpha: { start: 0.7, end: 0 },
lifespan: { min: 300, max: 600 },
frequency: 60,
quantity: 1,
tint: [0xff2200, 0xff6600, 0xffaa00, 0xffff00],
blendMode: 'ADD',
});
embers.setDepth(f.depth + 1);
f.setData('embers', embers);
}
}
// Animate fire glow + embers — resize/reposition column as fire moves
const mg = f.getData('manualGlow');
const em = f.getData('embers');
if (mg) {
if (fireVisible) {
const glowH = H - f.y + BLOCK * 2;
mg.setPosition(f.x, f.y + glowH / 2);
mg.setSize(BLOCK * 1.4, glowH);
mg.setAlpha(0.12 + Math.sin(this.time.now / 60) * 0.08);
}
else {
mg.setAlpha(0);
}
}
if (em) {
em.setPosition(f.x, f.y);
if (fireVisible) {
em.start();
}
else {
em.stop();
}
}
if (f.x < camLeft - 200) {
const gl = f.getData('manualGlow');
if (gl)
gl.destroy();
const sp = f.getData('sparks');
if (sp)
sp.destroy();
const emb = f.getData('embers');
if (emb)
emb.destroy();
const we = f.getData('warnEmbers');
if (we)
we.destroy();
const wg = f.getData('warnGlow');
if (wg) {
this.tweens.killTweensOf(wg);
wg.destroy();
}
f.destroy();
}
});
}
updatePiranhas(dtMs, camLeft) {
// Piranha plant animation
this.piranhaGroup.getChildren().forEach((p) => {
if (!p.active)
return;
let timer = p.getData('timer') + dtMs;
const pipeTopY = p.getData('pipeTopY');
const cycle = 4000;
const phase = (timer % cycle) / cycle;
// Only suppress if the player is directly on top of the pipe
const pipeX = p.getData('pipeX');
const dx = Math.abs(this.player.x - pipeX);
const onPipe = dx < BLOCK * 0.8 && this.player.y < pipeTopY && this.player.body.velocity.y >= 0;
if (onPipe) {
p.setVisible(false);
p.body.enable = false;
p.setData('timer', timer);
return;
}
if (phase < 0.25) {
const t = phase / 0.25;
p.y = pipeTopY + BLOCK * (1 - t);
p.setVisible(true);
p.body.enable = true;
}
else if (phase < 0.5) {
p.y = pipeTopY;
p.setVisible(true);
p.body.enable = true;
p.setTexture(Math.floor(timer / 200) % 2 === 0 ? 'piranha_0' : 'piranha_1');
}
else if (phase < 0.75) {
const t = (phase - 0.5) / 0.25;
p.y = pipeTopY + BLOCK * t;
p.setVisible(true);
p.body.enable = true;
}
else {
p.setVisible(false);
p.body.enable = false;
}
p.setData('timer', timer);
if (p.x < camLeft - 200)
p.destroy();
});
}
updateCrocs(camLeft) {
// Croc update — swim back and forth, cycle mouth open/closed
const now = this.time.now;
this.crocGroup.getChildren().forEach((croc) => {
if (croc.x < camLeft - 200) {
croc.destroy();
return;
}
// Mouth state cycling
const timer = croc.getData('timer');
if (now >= timer) {
const wasOpen = croc.getData('mouthOpen');
croc.setData('mouthOpen', !wasOpen);
croc.setTexture(wasOpen ? 'croc_closed' : 'croc_open');
// Closed longer than open (2-3s closed, 1-1.5s open)
croc.setData('timer', now + (wasOpen ? 2000 + Math.random() * 1000 : 1000 + Math.random() * 500));
}
// Swim within gap bounds
const gapStart = croc.getData('gapStart');
const gapEnd = croc.getData('gapEnd');
const margin = 24;
if (croc.x <= gapStart + margin) {
croc.setData('swimDir', 1);
croc.setVelocityX(30);
}
else if (croc.x >= gapEnd - margin) {
croc.setData('swimDir', -1);
croc.setVelocityX(-30);
}
});
}
cleanupOffscreen(camLeft) {
this.mushroomGroup.getChildren().forEach(m => {
if (m.x < camLeft - 100 || m.y > H + 100)
m.destroy();
});
this.fireballGroup.getChildren().forEach(fb => {
if (fb.x < camLeft - 100 || fb.x > camLeft + W + 200 || fb.y > H + 50)
fb.destroy();
});
// Fish cleanup — destroy when scrolled offscreen
this.fishGroup.getChildren().forEach((fish) => {
if (fish.x < camLeft - 200)
fish.destroy();
});
}
updateEnemy(e, camLeft) {
if (!e.active)
return;
const state = e.getData('state');
const kind = e.getData('kind');
const enemyType = e.getData('enemyType') || 'monster';
if (e.x < camLeft - BLOCK * 3) {
e.destroy();
return;
}
if (e.y > H + 50) {
e.destroy();
return;
}
if (!e.body)
return;
// Block-row patrol: idle until player approaches, then bounce within bounds
if (e.getData('patrolAwait')) {
if (Math.abs(this.player.x - e.x) < W) {
e.setData('patrolAwait', false);
e.setVelocityX(-80);
}
else {
e.setVelocityX(0);
return;
}
}
const pLeft = e.getData('patrolLeft');
if (pLeft !== undefined && pLeft !== null) {
const pRight = e.getData('patrolRight');
if (e.x <= pLeft) {
e.setVelocityX(80);
}
else if (e.x >= pRight) {
e.setVelocityX(-80);
}
}
if (state === 'walk' || state === 'flying') {
// Animate based on enemy type
if (enemyType === 'bat') {
const frame = Math.floor(this.time.now / 150) % 2;
e.setTexture(frame === 0 ? 'bat_0' : 'bat_1');
e.setDisplaySize(BLOCK, BLOCK);
const baseY = e.getData('baseY') || GROUND_Y - BLOCK * 2;
e.y = baseY + Math.sin(this.time.now / 400 + e.x * 0.01) * 40;
}
// monster, bulldog, snake all use anims — no manual texture swap needed
if (kind === 'rkoopa' && (e.body.blocked.down || e.body.touching.down)) {
const ahead = e.x + (e.body.velocity.x > 0 ? BLOCK : -BLOCK);
if (this.isInGap(ahead)) {
e.setVelocityX(-e.body.velocity.x);
}
}
e.flipX = e.body.velocity.x > 0;
}
else if (state === 'shell_still') {
let timer = e.getData('timer') - 1;
e.setData('timer', timer);
if (timer <= 0) {
e.setData('state', 'walk');
e.setVelocityX(-90);
}
}
}
onPlayerHitBrick(_player, brick) {
if (!this.player.body.touching.up)
return;
if (Math.abs(brick.x - this.player.x) > BLOCK * 0.55)
return;
this.collectCoinsAbove(brick.x, brick.y);
this.knockEnemiesAbove(brick.x, brick.y);
if (this.isBig) {
brick.destroy();
this.addScore(50, brick.x, brick.y - 20);
}
else {
// Small player: bump animation only (no destruction)
if (!brick.getData('bumping')) {
brick.setData('bumping', true);
const origY = brick.y;
this.tweens.add({
targets: brick, y: origY - 6, yoyo: true, duration: 80,
onComplete: () => { brick.y = origY; brick.setData('bumping', false); }
});
}
}
}
onPlayerHitQBlock(_player, q) {
if (q.getData('hit'))
return;
if (!this.player.body.touching.up)
return;
if (Math.abs(q.x - this.player.x) > BLOCK * 0.55)
return;
this.collectCoinsAbove(q.x, q.y);
this.knockEnemiesAbove(q.x, q.y);
q.setData('hit', true);
q.setTexture('qblock_used');
q.setDisplaySize(BLOCK, BLOCK);
this.tweens.add({ targets: q, y: q.y - 6, yoyo: true, duration: 100 });
const reward = q.getData('reward');
if (reward === 'mushroom' && !this.isBig) {
const m = this.mushroomGroup.create(q.x, q.y - BLOCK, 'mushroom');
m.body.setSize(28, 28);
m.setVelocityX(120);
m.setBounceX(1);
m.body.setMaxVelocity(200, 600);
}
else {
this.popCoin(q.x, q.y);
// Height bonus — higher ?-blocks reward more points for the effort
const heightAboveGround = GROUND_Y - q.y;
const heightBonus = heightAboveGround > BLOCK * 4 ? 300 : heightAboveGround > BLOCK * 2 ? 100 : 0;
this.addScore(200 + heightBonus, q.x, q.y - 20);
}
}
popCoin(x, y) {
const c = this.add.image(x, y, 'coin0').setDepth(50);
c.setDisplaySize(BLOCK * 0.7, BLOCK * 0.9);
this.tweens.add({
targets: c,
y: y - BLOCK * 2.2,
duration: 350,
ease: 'Sine.easeOut',
onComplete: () => {
this.tweens.add({
targets: c, y: y - BLOCK * 1.6, alpha: 0,
duration: 200, onComplete: () => c.destroy(),
});
},
});
}
onPlayerCoin(_player, c) {
c.destroy();
this.addScore(100, c.x, c.y);
this.sfx('nr_coin', 0.2);
}
/** Collect any coins sitting directly above a block (within 1 block). */
collectCoinsAbove(blockX, blockY) {
this.coinGroup.getChildren().forEach((c) => {
if (!c.active)
return;
const dx = Math.abs(c.x - blockX);
const dy = blockY - c.y; // coin should be above (positive = above)
if (dx < BLOCK * 0.7 && dy > 0 && dy < BLOCK * 1.5) {
// Pop the coin upward then destroy
this.tweens.add({
targets: c,
y: c.y - BLOCK,
alpha: 0,
duration: 300,
onComplete: () => c.destroy(),
});
this.addScore(100, c.x, c.y);
this.sfx('nr_coin', 0.2);
}
});
}
/** Knock out any enemy standing on top of a block that was hit from below. */
knockEnemiesAbove(blockX, blockY) {
this.enemyGroup.getChildren().forEach((e) => {
if (!e.active)
return;
const dx = Math.abs(e.x - blockX);
const dy = blockY - e.y; // enemy should be above (positive = above)
if (dx < BLOCK * 1.0 && dy > 0 && dy < BLOCK * 2) {
this.addScore(200, e.x, e.y - 10);
this.sfx('nr_stomp', 0.25);
// Launch enemy upward then destroy
e.setVelocityY(-400);
e.setVelocityX((Math.random() - 0.5) * 200);
e.flipY = true;
e.body.setAllowGravity(true);
e.setData('state', 'dead');
this.time.delayedCall(800, () => { if (e.active)
e.destroy(); });
}
});
}
onPlayerMushroom(_player, m) {
m.destroy();
if (!this.isBig) {
this.isBig = true;
this.addScore(1000, this.player.x, this.player.y - 20);
this.sfx('nr_powerup');
// Growth flash — briefly golden then normal
this.player.setTint(0xffdd00);
this.time.delayedCall(300, () => {
if (this.isBig)
this.player.clearTint();
});
// Add visible glow effect around the player (Ellipse — preFX doesn't render in WebKit)
if (!this.player.getData('hasGlow')) {
const glow = this.add.ellipse(this.player.x, this.player.y - PLAYER_H / 2, PLAYER_W * 1.4, PLAYER_H * 1.4, 0xffdd00, 1.0);
glow.setDepth(this.player.depth - 1);
glow.setAlpha(0.2);
glow.setBlendMode(Phaser.BlendModes.ADD);
this.player.setData('hasGlow', true);
this.player.setData('glowFx', glow);
}
}
}
onPlayerHeart(_player, h) {
h.destroy();
this.lives++;
this.syncLivesToHUD();
this.addScore(2000, h.x, h.y - 10);
this.sfx('nr_extralife');
// Green flash to indicate extra life
this.cameras.main.flash(300, 100, 255, 100, false);
}
onPlayerEnemy(_player, e) {
if (this.invincible > 0 || this.stompGrace > 0 || this.shrinkTimer > 0)
return;
const state = e.getData('state');
const kind = e.getData('kind');
const playerBottom = this.player.y;
const enemyTop = e.y - e.displayHeight;
const stomping = this.player.body.velocity.y > 50 &&
playerBottom < enemyTop + e.displayHeight * 0.5;
if (stomping) {
this.player.setVelocityY(-450);
this.stompGrace = 417;
this.sfx('nr_stomp', 0.25);
if (kind === 'goomba') {
this.killGoomba(e);
}
else if (state === 'walk') {
this.becomeShell(e);
this.addScore(200, e.x, e.y - 20);
}
else if (state === 'shell_still') {
const dir = this.player.x < e.x ? 1 : -1;
e.setData('state', 'shell');
e.setVelocityX(dir * 400);
this.addScore(100, e.x, e.y - 20);
}
else if (state === 'shell') {
e.setData('state', 'shell_still');
e.setData('timer', 300);
e.setVelocityX(0);
this.addScore(100, e.x, e.y - 20);
}
}
else if (state === 'shell_still') {
const dir = this.player.x < e.x ? 1 : -1;
e.setData('state', 'shell');
e.setVelocityX(dir * 400);
this.stompGrace = 250;
this.addScore(100, e.x, e.y - 20);
}
else {
this.takeHit();
}
}
// Replace the enemy with a shell sprite using the dead frame.
becomeShell(e) {
const kind = e.getData('kind');
const x = e.x;
e.destroy();
const shell = this.enemyGroup.create(x, GROUND_Y, 'enemy', 4);
shell.setOrigin(0.5, 1);
shell.setDisplaySize(BLOCK, BLOCK * 0.7);
shell.body.setGravityY(1800);
shell.body.setAllowGravity(true);
shell.setVelocityX(0);
shell.setBounceX(1);
shell.setCollideWorldBounds(false);
shell.setData('kind', kind);
shell.setData('state', 'shell_still');
shell.setData('timer', 300);
}
// Goomba "death": disable the body so nothing collides with it again, fade
// and shrink it visually, then destroy. No state-machine, no body resizing
// hacks — this avoids the floating/misaligned-body bugs.
killGoomba(e) {
e.setData('state', 'dying');
e.disableBody(false, false);
e.anims.stop();
if (e.getData('enemyType') === 'snake') {
e.setFrame(4);
e.setDisplaySize(BLOCK, BLOCK * 0.5); // squished
}
else {
e.setFrame(4); // dead frame in all strips
}
this.addScore(200, e.x, e.y - 20);
this.tweens.add({
targets: e,
scaleY: 0.3,
alpha: 0,
duration: 250,
onComplete: () => e.destroy(),
});
}
onEnemyVsEnemy(a, b) {
const aState = a.getData('state');
const bState = b.getData('state');
if (aState === 'shell' && bState !== 'dying' && bState !== 'shell') {
this.killByShell(b);
}
else if (bState === 'shell' && aState !== 'dying' && aState !== 'shell') {
this.killByShell(a);
}
}
killByShell(e) {
if (e.getData('kind') === 'goomba') {
this.killGoomba(e);
}
else {
// Koopa hit by shell: knock it offscreen with an upward arc.
e.setData('state', 'dying');
e.disableBody(false, false);
this.addScore(100, e.x, e.y - 20);
this.tweens.add({
targets: e, y: e.y - 80, alpha: 0, angle: 360,
duration: 500, onComplete: () => e.destroy(),
});
}
}
onFireballHitSolid(fb, _solid) {
if (fb.body.blocked.down) {
fb.setVelocityY(-350);
}
else {
fb.destroy();
}
}
onFireballEnemy(fb, e) {
const st = e.getData('state');
if (st === 'dying')
return;
fb.destroy();
this.killByShell(e);
}
throwFireball() {
const dir = this.facingRight ? 1 : -1;
const fb = this.fireballGroup.create(this.player.x + dir * 20, this.player.y + 20, 'fireball');
fb.body.setSize(14, 14);
fb.setVelocityX(dir * 450);
fb.setVelocityY(-100);
fb.setBounceY(0.6);
this.sfx('nr_fireball', 0.2);
}
takeHit() {
if (this.isBig) {
this.isBig = false;
this.player.clearTint();
this.shrinkTimer = 1000;
this.sfx('nr_hit');
// glow handled by preFX
}
else {
this.die();
}
}
die() {
if (this.dead)
return;
this.lives--;
this.syncLivesToHUD();
this.dead = true;
this.deadTimer = 1200;
this.sfx('nr_die', 0.4);
this.player.setVelocity(0, -500);
this.player.body.checkCollision.none = true;
this.isBig = false;
this.player.clearTint();
// glow handled by preFX
if (this.parachuteMode)
this.endParachute();
}
doRespawn() {
this.dead = false;
const deathX = Math.max(this.lastSafeX, this.cameras.main.scrollX + 200);
// Find a safe spot — search BACKWARD first to respawn before the hazard
const isSafe = (wx) => {
if (this.isInGap(wx))
return false;
if (this.isNearObstacle(wx))
return false;
const fires = this.fireGroup.getChildren();
for (const f of fires) {
if (f.active && Math.abs(wx - f.x) < BLOCK * 1.5)
return false;
}
const enemies = this.enemyGroup.getChildren();
for (const e of enemies) {
if (e.active && Math.abs(wx - e.x) < BLOCK * 3)
return false;
}
return true;
};
// Search backward first (up to 15 blocks behind death point)
let x = deathX;
const minX = Math.max(this.cameras.main.scrollX + 100, deathX - BLOCK * 15);
let backX = deathX - BLOCK;
while (backX >= minX) {
if (isSafe(backX)) {
x = backX;
break;
}
backX -= BLOCK;
}
// If no safe spot behind, search forward as fallback
if (x === deathX && !isSafe(x)) {
let tries = 0;
while (!isSafe(x) && tries < 50) {
x += BLOCK;
tries++;
}
}
this.player.setPosition(x, GROUND_Y - 100);
this.player.setVelocity(0, 0);
this.player.body.checkCollision.none = false;
this.player.clearTint();
this.invincible = 1500;
this.shrinkTimer = 0;
this.stompGrace = 0;
// glow handled by preFX
}
respawn() {
if (this.lives <= 0) {
this.sfx('nr_gameover');
// Keep dead=true so update() doesn't run while overlay is showing
this.player.setVisible(false);
this.player.setVelocity(0, 0);
this.player.body.checkCollision.none = true;
this.showGameOver(this.score, () => {
this.sfx('nr_startlevel');
this.lives = 3;
this.score = 0;
this.syncScoreToHUD();
this.syncLivesToHUD();
this.player.setVisible(true);
this.doRespawn();
});
return;
}
this.doRespawn();
}
onPlayerBridge(_player, _tile) {
// Collision still needed for standing — collapse is handled by proximity in update
}
onPlayerBounce(_player, pad) {
if (!this.player.body.touching.down)
return;
this.player.setVelocityY(-1200);
// Compress animation on the pad
this.tweens.add({
targets: pad,
scaleY: 0.5,
duration: 100,
yoyo: true,
ease: 'Power2',
});
this.addScore(50, pad.x, pad.y - 20);
this.sfx('nr_bounce', 0.3);
}
onPlayerFlag(_player, flag) {
flag.destroy();
this.currentLevel++;
this.currentBiome = (this.currentBiome + 1) % 4;
this.syncLevelToHUD(this.currentLevel);
this.addScore(5000, flag.x, flag.y - 30);
this.sfx('nr_flag');
const cam = this.cameras.main;
cam.flash(500, 255, 255, 255, false);
const txt = this.add.text(this.player.x, this.player.y - 80, `LEVEL ${this.currentLevel}!`, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '24px',
color: '#ffdd00',
stroke: '#000',
strokeThickness: 4,
}).setOrigin(0.5).setDepth(1000);
this.tweens.add({
targets: txt,
y: txt.y - 60,
alpha: 0,
duration: 2000,
onComplete: () => txt.destroy(),
});
}
onPlayerPiranha(_player, _p) {
if (this.invincible > 0 || this.shrinkTimer > 0)
return;
if (this.isBig) {
this.isBig = false;
this.shrinkTimer = 1000;
this.invincible = 1500;
// glow handled by preFX
}
else {
this.die();
}
}
onPlayerFire(_player, _f) {
if (this.invincible > 0 || this.shrinkTimer > 0)
return;
if (this.isBig) {
this.isBig = false;
this.shrinkTimer = 1000;
this.invincible = 1500;
// glow handled by preFX
}
else {
this.die();
}
}
onPlayerCroc(_player, croc) {
if (this.invincible > 0 || this.shrinkTimer > 0)
return;
const pBody = this.player.body;
const stomping = pBody.velocity.y > 0 && pBody.bottom <= croc.body.top + 10;
if (stomping && !croc.getData('mouthOpen')) {
this.addScore(200);
croc.destroy();
pBody.setVelocityY(-500);
this.sfx('nr_stomp');
}
else {
if (this.isBig) {
this.isBig = false;
this.shrinkTimer = 1000;
this.invincible = 1500;
}
else {
this.die();
}
}
}
onPlayerFish(_player, fish) {
if (this.invincible > 0 || this.shrinkTimer > 0)
return;
if (!fish.visible)
return;
if (this.isBig) {
this.isBig = false;
this.shrinkTimer = 1000;
this.invincible = 1500;
}
else {
this.die();
}
}
startWarp(sourcePipe) {
this.warping = true;
this.sfx('nr_warp');
this.player.setVelocity(0, 0);
this.player.body.setAllowGravity(false);
// Sparkle particle burst at pipe entrance
const particles = this.add.particles(this.player.x, this.player.y, 'coin0', {
speed: { min: 40, max: 120 },
angle: { min: 200, max: 340 },
scale: { start: 0.3, end: 0 },
lifespan: 600,
quantity: 12,
emitting: false,
tint: [0x00ff00, 0x44ff44, 0xffff00, 0xffffff],
});
particles.setDepth(15);
particles.explode(12);
this.time.delayedCall(800, () => particles.destroy());
// Fade + shrink player as they enter the pipe
this.tweens.add({
targets: this.player,
y: sourcePipe.y + BLOCK,
scaleX: 0.3,
scaleY: 0.3,
alpha: 0,
duration: 500,
onComplete: () => {
// Reset player scale/alpha for exit
this.player.setScale(1);
this.player.setAlpha(1);
// Ensure terrain is generated far enough ahead for a destination
const aheadX = sourcePipe.x + BLOCK * 30;
if (this.genX < aheadX) {
this.generateLevel(this.genX, aheadX);
this.extendGround(this.genX, aheadX + W);
}
// Safety check — is a landing spot free of hazards?
const isLandingSafe = (wx) => {
if (this.isInGap(wx))
return false;
if (this.isNearObstacle(wx))
return false;
const fires = this.fireGroup.getChildren();
for (const f of fires) {
if (f.active && Math.abs(wx - f.x) < BLOCK * 2)
return false;
}
const enemies = this.enemyGroup.getChildren();
for (const e of enemies) {
if (e.active && Math.abs(wx - e.x) < BLOCK * 3)
return false;
}
return true;
};
// Find a warp-eligible pipe well ahead of the source in a safe spot
const minX = sourcePipe.x + BLOCK * 15;
const pipes = this.pipeGroup.getChildren()
.filter((p) => p.x > minX && !p.getData('warp') && !p.getData('gold'))
.sort((a, b) => a.x - b.x);
// Group pipes by x-position to find distinct pipe columns
let destPipe = null;
const visited = new Set();
for (const p of pipes) {
const col = Math.round(p.x / BLOCK);
if (visited.has(col))
continue;
visited.add(col);
if (isLandingSafe(p.x)) {
destPipe = p;
break;
}
}
if (destPipe) {
// Find the topmost segment at this pipe's x position
const topSeg = pipes.filter((p) => Math.abs(p.x - destPipe.x) < BLOCK)
.sort((a, b) => a.y - b.y)[0];
const destTop = topSeg.y - BLOCK / 2;
this.player.setPosition(topSeg.x, destTop + BLOCK);
this.player.setVisible(false);
this.tweens.add({
targets: this.player,
y: destTop - 10,
duration: 400,
onStart: () => {
this.player.setVisible(true);
// Sparkle burst at exit pipe
const exitParticles = this.add.particles(this.player.x, this.player.y, 'coin0', {
speed: { min: 40, max: 120 },
angle: { min: 200, max: 340 },
scale: { start: 0.3, end: 0 },
lifespan: 600,
quantity: 10,
emitting: false,
tint: [0x00ff00, 0x44ff44, 0xffff00, 0xffffff],
});
exitParticles.setDepth(15);
exitParticles.explode(10);
this.time.delayedCall(800, () => exitParticles.destroy());
},
onComplete: () => {
this.player.body.setAllowGravity(true);
this.warping = false;
this.addScore(200, this.player.x, this.player.y - 20);
},
});
}
else {
// No safe pipe found — warp to safe ground ahead
let landX = sourcePipe.x + BLOCK * 18;
let tries = 0;
while (!isLandingSafe(landX) && tries < 30) {
landX += BLOCK;
tries++;
}
this.player.setPosition(landX, GROUND_Y - BLOCK);
this.player.setVisible(true);
this.player.body.setAllowGravity(true);
this.warping = false;
this.addScore(200, this.player.x, this.player.y - 20);
}
},
});
}
startParachute(pipe) {
this.warping = true;
this.parachuteMode = true;
this.sfx('nr_warp');
this.player.setVelocity(0, 0);
this.player.body.setAllowGravity(false);
// Sparkle particle burst at golden pipe entrance
const particles = this.add.particles(this.player.x, this.player.y, 'coin0', {
speed: { min: 50, max: 140 },
angle: { min: 200, max: 340 },
scale: { start: 0.4, end: 0 },
lifespan: 700,
quantity: 16,
emitting: false,
tint: [0xffdd00, 0xffaa00, 0xffffff, 0xff8800],
});
particles.setDepth(15);
particles.explode(16);
this.time.delayedCall(900, () => particles.destroy());
// Fade + shrink into pipe
this.tweens.add({
targets: this.player,
y: pipe.y + BLOCK,
scaleX: 0.3,
scaleY: 0.3,
alpha: 0,
duration: 500,
onComplete: () => {
// Reset scale and alpha from pipe entry animation
this.player.setScale(1);
this.player.setAlpha(1);
const targetX = this.cameras.main.scrollX + W / 2;
this.player.setPosition(targetX, 60);
this.player.setVisible(true);
this.player.body.setAllowGravity(true);
this.player.body.setGravityY(42);
this.player.setMaxVelocity(200, 144);
this.warping = false;
this.parachuteSprite = this.add.sprite(this.player.x, this.player.y - 80, 'parachute');
this.parachuteSprite.setDisplaySize(96, 120);
this.parachuteSprite.setOrigin(0.5, 1); // bottom-center anchored to player's head
this.parachuteSprite.setDepth(9);
for (let i = 0; i < 8; i++) {
const cx = targetX + (Math.random() - 0.5) * W * 0.6;
const cy = 100 + Math.random() * (GROUND_Y - 200);
const c = this.coinGroup.create(cx, cy, 'coin0');
c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65);
c.body.setAllowGravity(false);
c.body.setSize(12, 18);
c.setData('parachuteCoin', true);
}
this.parachuteTimer = 0;
this.parachuteFlyingEnemies = [];
// Start looping wind sound
try {
this.windSound = this.sound.add('nr_wind', { volume: 0.15, loop: true });
this.windSound.play();
}
catch { }
},
});
}
endParachute() {
this.parachuteMode = false;
// Stop wind sound
if (this.windSound) {
try {
this.windSound.stop();
}
catch { }
this.windSound = undefined;
}
if (this.parachuteSprite) {
this.parachuteSprite.destroy();
this.parachuteSprite = undefined;
}
this.player.body.setGravityY(1800);
this.player.setMaxVelocity(700, 900);
this.player.setAccelerationX(0);
// Re-enable camera follow after parachute
this.cameras.main.startFollow(this.player, true, 0.15, 0.05, -W * 0.2, 0);
this.parachuteFlyingEnemies.forEach(e => { if (e.active)
e.destroy(); });
this.parachuteFlyingEnemies = [];
this.addScore(500, this.player.x, this.player.y - 30);
}
shutdown() {
super.shutdown();
// Destroy all physics groups and their children
const groups = [
this.groundGroup, this.brickGroup, this.qblockGroup, this.pipeGroup,
this.coinGroup, this.mushroomGroup, this.heartGroup, this.fireballGroup,
this.enemyGroup, this.bridgeGroup, this.bounceGroup, this.flagGroup,
this.piranhaGroup, this.fireGroup, this.crocGroup, this.fishGroup,
];
for (const g of groups) {
if (g && g.clear)
try {
g.clear(true, true);
}
catch { }
}
// Destroy player and extra sprites
this.destroyObj(this.player);
this.destroyObj(this.parachuteSprite);
this.parachuteSprite = undefined;
this.destroyObj(this.glowSprite);
this.glowSprite = undefined;
// Stop wind sound
if (this.windSound) {
try {
this.windSound.stop();
}
catch { }
this.windSound = undefined;
}
// Clean up flying enemies from parachute mode
this.parachuteFlyingEnemies.forEach(e => { if (e.active)
e.destroy(); });
this.parachuteFlyingEnemies = [];
}
}
//# sourceMappingURL=NinjaRunner.js.map