Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,17 @@ export class AudioManager {
);
this.noiseBurst({ dur: 0.08, vol: 0.08, freq: 2400 });
break;
case 'powerup':
// Bright rising four-note pickup chime.
[523, 784, 1046, 1568].forEach((f, i) =>
this.tone({ freq: f, dur: 0.1, type: 'triangle', vol: 0.18, delay: i * 0.05 }),
);
break;
case 'bubblePop':
// Soap-bubble pop: soft hiss plus a falling blip.
this.noiseBurst({ dur: 0.08, vol: 0.16, freq: 1600 });
this.tone({ freq: 900, to: 300, dur: 0.16, type: 'sine', vol: 0.2 });
break;
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ export const CFG = {
spitter: { range: 520, band: 150, fireCd: 1.8, projSpeed: 1.06, projGravity: 700 },
/** Magma King: charge speed (px/s) and magma-glob fire speed. */
boss: { chargeSpeed: 640, shootSpeed: 430 },
/** Enemy-kill power-up capsules: Magnet, Double Jump, Bubble shield. */
powerup: {
dropChance: 0.25, // share of stomped enemies that drop a capsule
magnetDur: 8, // seconds of crystal attraction
magnetRange: 150, // pickup radius of the magnet (px)
magnetSpeed: 520, // how fast attracted crystals home in (px/s)
doubleJumpDur: 15, // seconds of extra air jumps
doubleJumpVel: 600, // air-jump impulse (just under the ground jump)
expireT: 10, // seconds a dropped capsule waits before evaporating
},
} as const;

export const VW = CFG.view.w;
Expand Down
35 changes: 35 additions & 0 deletions src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { generateDailyLevel, dailySeed, dailyLabel, rexCode } from './daily';
import { GhostRecorder, GhostPlayer } from './ghost';
import { drawDecor } from './decor';
import { Sprite, SKINS, skinUnlocked } from './sprite';
import { drawPowerUpIcon, POWERUP_COLORS } from './powerup';
import type { PowerUpType } from './powerup';
import type { GameCtx } from './ctx';
import type { Checkpoint } from './checkpoint';
import type { Platform } from './platform';
Expand Down Expand Up @@ -853,6 +855,12 @@ export class Game implements GameCtx {
for (const f of this.level!.fossils) {
if (!f.collected) f.draw(ctx, this.time);
}
// Power-up capsules (enemy drops)
for (const pw of this.level!.powerups) {
if (pw.collected) continue;
if (pw.x + 30 < camX - 40 || pw.x - 30 > camX + VW + 40) continue;
pw.draw(ctx, this.time);
}
for (const e of this.level!.enemies) {
if (e.dead) continue;
if (e.x + e.w < camX - 60 || e.x > camX + VW + 60) continue;
Expand Down Expand Up @@ -1182,6 +1190,33 @@ export class Game implements GameCtx {
}
}
ctx.restore();
// Active power-up chips (under the heart panel, left)
if (this.player) {
const p = this.player;
const chips: { type: PowerUpType; frac: number }[] = [];
if (p.magnetT > 0) chips.push({ type: 'magnet', frac: p.magnetT / CFG.powerup.magnetDur });
if (p.doubleJumpT > 0) chips.push({ type: 'double', frac: p.doubleJumpT / CFG.powerup.doubleJumpDur });
if (p.bubble) chips.push({ type: 'bubble', frac: 1 });
let chipX = 22;
for (const chip of chips) {
ctx.fillStyle = 'rgba(20,30,45,0.6)';
this.roundRect(ctx, chipX, 80, 46, 26, 8);
ctx.fill();
ctx.strokeStyle = POWERUP_COLORS[chip.type];
ctx.lineWidth = 1.5;
this.roundRect(ctx, chipX, 80, 46, 26, 8);
ctx.stroke();
drawPowerUpIcon(ctx, chip.type, chipX + 14, 93);
// Remaining-time bar (full for the one-hit bubble)
ctx.fillStyle = 'rgba(0,0,0,0.45)';
this.roundRect(ctx, chipX + 27, 87, 14, 4, 2);
ctx.fill();
ctx.fillStyle = POWERUP_COLORS[chip.type];
this.roundRect(ctx, chipX + 27, 87, Math.max(2, 14 * chip.frac), 4, 2);
ctx.fill();
chipX += 54;
}
}
// Progress toward the nest (top centre, between the panels)
if (this.player && this.level) this.drawProgress(ctx);
// Magma King: health bar + orb indicators (top centre, under the track)
Expand Down
21 changes: 21 additions & 0 deletions src/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { Projectile } from './projectile';
import type { ProjectileKind } from './projectile';
import { Fossil } from './fossil';
import { MagmaKing } from './boss';
import { PowerUp } from './powerup';
import type { PowerUpType } from './powerup';

export class Level {
width: number;
Expand All @@ -39,6 +41,8 @@ export class Level {
fossils: Fossil[];
/** The Magma King (Molten Nest only). */
boss: MagmaKing | null;
/** Power-up capsules dropped by stomped enemies. */
powerups: PowerUp[];
readonly game: GameCtx;

constructor(d: LevelDef, game: GameCtx, enemySpeed = 1, levelIdx = 0) {
Expand Down Expand Up @@ -77,6 +81,12 @@ export class Level {
this.boss = d.boss
? new MagmaKing(d.boss, this, enemySpeed, d.orbs ?? [])
: null;
this.powerups = [];
}

/** Spawn a power-up capsule (enemy-kill drop). */
spawnPowerUp(type: PowerUpType, x: number, y: number): void {
this.powerups.push(new PowerUp(type, x, y));
}

solidAt(x: number, y: number): boolean {
Expand Down Expand Up @@ -119,13 +129,24 @@ export class Level {
this.boss?.update(dt, t, player);
for (const pr of this.projectiles) pr.update(dt, this, player);
this.projectiles = this.projectiles.filter((pr) => !pr.dead);
for (const pw of this.powerups) pw.update(dt);
this.powerups = this.powerups.filter((pw) => {
if (pw.collected) return false;
if (pw.life <= 0) {
// Left behind: evaporate with a small poof
this.game.burst(pw.x, pw.y, 6, ['#ffffff', '#cfe8ff'], 'dot', 90);
return false;
}
return true;
});
}

reset(): void {
for (const p of this.platforms) p.reset();
for (const s of this.springs) s.reset();
for (const p of this.plates) p.reset();
this.projectiles = [];
this.powerups = [];
for (const c of this.crystals) c.collected = false;
for (const h of this.hearts) h.collected = false;
for (const f of this.fossils) f.collected = false;
Expand Down
90 changes: 89 additions & 1 deletion src/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import type { Input } from './input';
import type { Level } from './level';
import type { Platform } from './platform';
import type { GameCtx } from './ctx';
import type { Crystal } from './crystal';
import { rollDrop } from './powerup';
import type { PowerUpType } from './powerup';

export type PlayerState = 'idle' | 'run' | 'jump' | 'fall' | 'hurt' | 'dead' | 'victory';
export type DamageKind = 'spikes' | 'lava' | 'enemy' | 'rock' | 'pit' | 'spit';
Expand Down Expand Up @@ -64,6 +67,13 @@ export class Player {
rainbow = false;
/** Cosmetic skin id (see SKINS in sprite.ts); set by the Game. */
skin = 'classic';
/** Power-up timers: seconds of remaining effect (0 = inactive). */
magnetT = 0;
doubleJumpT = 0;
/** One-hit bubble shield from a Bubble capsule. */
bubble = false;
/** Air jumps left this flight (double-jump power-up). */
airJumps = 0;
private readonly game: GameCtx;

constructor(x: number, y: number, game: GameCtx) {
Expand Down Expand Up @@ -94,6 +104,10 @@ export class Player {
this.hurtT = 0;
this.walkDustT = 0;
this.jumpCutPending = false;
this.magnetT = 0;
this.doubleJumpT = 0;
this.bubble = false;
this.airJumps = 0;
}

get feet(): number {
Expand Down Expand Up @@ -140,6 +154,8 @@ export class Player {

this.hurtT = Math.max(0, this.hurtT - dt);
this.invulnT = Math.max(0, this.invulnT - dt);
this.magnetT = Math.max(0, this.magnetT - dt);
this.doubleJumpT = Math.max(0, this.doubleJumpT - dt);
this.landT = Math.max(0, this.landT - dt);
// Ease squash & stretch back to neutral
this.squashX = lerp(this.squashX, 1, dt * 12);
Expand Down Expand Up @@ -178,6 +194,25 @@ export class Player {
this.game.burst(this.x + this.w / 2, this.feet, 5, ['#e8dcc8'], 'dust', 90);
this.game.audio.play('jump');
}
// Double-jump power-up: one extra mid-air jump per flight. Re-reads
// the buffer (a ground jump this frame already consumed it).
if (
input.jumpBufferT >= 0 &&
t - input.jumpBufferT <= P.jumpBuffer &&
!this.grounded &&
this.coyoteT <= 0 &&
this.doubleJumpT > 0 &&
this.airJumps < 1
) {
input.jumpBufferT = -1;
this.vy = -CFG.powerup.doubleJumpVel;
this.airJumps += 1;
this.squashX = 0.82;
this.squashY = 1.22;
this.jumpCutPending = true;
this.game.burst(this.x + this.w / 2, this.feet, 8, ['#8fd8ff', '#cfe8ff'], 'dot', 120);
this.game.audio.play('jump');
}
// Variable jump height: releasing early cuts the ascent, but only once
// (edge-triggered) so stomp bounces and lava flicks keep their full pop.
if (this.jumpCutPending) {
Expand Down Expand Up @@ -249,6 +284,8 @@ export class Player {
}
}
}
// Landing resets the air-jump allotment
if (this.grounded) this.airJumps = 0;

// --- Spring pads: launch when falling (or walking) onto one ---
for (const s of level.springs) {
Expand Down Expand Up @@ -294,9 +331,10 @@ export class Player {
}
}

// --- Crystals ---
// --- Crystals (the Magnet power-up pulls them in first) ---
for (const c of level.crystals) {
if (c.collected) continue;
if (this.magnetT > 0) this.attract(c, dt);
if (overlap(this.rect, c.rect)) {
c.collected = true;
this.game.collectCrystal(c.x, c.y, c.bonus);
Expand Down Expand Up @@ -338,11 +376,22 @@ export class Player {
this.game.audio.play('stomp');
if (e.type === 'spitter') level.popProjectile(e.x + e.w / 2, e.y + e.h / 2);
vibrate(30);
const drop = rollDrop();
if (drop) level.spawnPowerUp(drop, e.x + e.w / 2, e.y + e.h / 2);
} else if (this.invulnT <= 0) {
this.damage(e, 'enemy');
}
}

// --- Power-up capsules (enemy drops) ---
for (const pw of level.powerups) {
if (!pw.alive) continue;
if (overlap(this.rect, pw.rect)) {
pw.collected = true;
this.applyPowerup(pw.type, pw.x, pw.y);
}
}

// --- Checkpoints ---
for (const cp of level.checkpoints) {
if (!cp.active && overlap(this.rect, cp.rect)) cp.activate();
Expand All @@ -359,6 +408,35 @@ export class Player {
}
}

/** Magnet power-up: pulls a nearby crystal toward Rex's centre. */
private attract(c: Crystal, dt: number): void {
const dx = this.x + this.w / 2 - c.x;
const dy = this.y + this.h / 2 - c.y;
const d = Math.hypot(dx, dy);
if (d > CFG.powerup.magnetRange || d < 1) return;
const step = CFG.powerup.magnetSpeed * dt;
c.x += (dx / d) * step;
c.y += (dy / d) * step;
}

/** Pick up a power-up capsule: applies the timed effect. */
applyPowerup(type: PowerUpType, x: number, y: number): void {
const P = CFG.powerup;
if (type === 'magnet') {
this.magnetT = P.magnetDur;
this.game.addStatus('Magnet!', '#ff9db0');
} else if (type === 'double') {
this.doubleJumpT = P.doubleJumpDur;
this.airJumps = 0;
this.game.addStatus('Double Jump!', '#8fd8ff');
} else {
this.bubble = true;
this.game.addStatus('Bubble Shield!', '#bff4ff');
}
this.game.audio.play('powerup');
this.game.burst(x, y, 14, ['#ffffff', '#cfe8ff', '#ffd257'], 'dot', 150);
}

enemyColors(type: string): string[] {
if (type === 'beetle') return ['#7a3b2e', '#5c2c22', '#c96f4a'];
if (type === 'trike') return ['#7d97ad', '#5d7690', '#c3d3e0'];
Expand All @@ -376,6 +454,16 @@ export class Player {
this.game.addStatus('Invulnerable!', '#8fe3ff');
return;
}
if (this.bubble) {
// The bubble absorbs the hit: pop, brief grace, no heart lost.
this.bubble = false;
this.invulnT = CFG.player.invulnTime * 0.5;
this.hurtT = 0.25;
this.game.audio.play('bubblePop');
this.game.burst(this.x + this.w / 2, this.y + this.h / 2, 16, ['#bff4ff', '#8fe3ff', '#fff'], 'dot', 170);
this.game.addStatus('Bubble popped!', '#bff4ff');
return;
}
this.hearts -= 1;
this.invulnT = CFG.player.invulnTime;
this.hurtT = 0.5;
Expand Down
Loading
Loading