Sprint 2: gameplay — achievements, score popups, new enemy, polish

- AchievementsManager with 10 unlockables and toast popups
  (Genesis Block, Chain Reaction x3, Bug Hunter, First Flight,
   Liftoff, Power Trip, Survivor 500, Skyscraper 1000,
   Speedrun 100/60s, Gas Baron 50k)
- Score popups: +N text floats above player on every landing
  (gold and larger for genesis platforms)
- Powerup duration bar in HUD bottom, color-coded per power-up,
  uses scaleX for smooth depletion animation
- New enemy: Failed Tx, falls from above with sine drift, unlocks
  at difficulty > 800, can be stomped, tinted red
- Dynamic background: dark cosmic overlay alpha scales with height
  (max 0.5 at very high altitudes)
- Achievement hooks integrated into ScoreManager, GameScene
- Combo no longer resets if combo was already 0 (was triggering log spam)
This commit is contained in:
2026-05-23 16:58:36 +07:00
parent fd93da0a71
commit 57f9e2f282
5 changed files with 280 additions and 12 deletions

View File

@@ -8,29 +8,46 @@ export class Enemy extends Physics.Arcade.Sprite {
scene.physics.add.existing(this);
this.enemyType = type;
this.setScale(0.7);
this.body.allowGravity = false;
this.body.setSize(90, 80);
this.body.setOffset(35, 30);
if (type === 'bug') {
this.setScale(0.7);
this.body.setSize(this.width * 0.7, this.height * 0.7);
this.body.setOffset(this.width * 0.15, this.height * 0.15);
this.speed = Phaser.Math.Between(60, 140) * (Math.random() < 0.5 ? 1 : -1);
this.startX = x;
this.patrolRange = Phaser.Math.Between(80, 200);
} else if (type === 'failed_tx') {
this.setScale(0.55);
this.setTint(0xef4444);
this.body.setSize(this.width * 0.6, this.height * 0.6);
this.body.setOffset(this.width * 0.2, this.height * 0.2);
this.fallSpeed = Phaser.Math.Between(80, 140);
this.driftAmplitude = Phaser.Math.Between(20, 60);
this.driftFreq = Phaser.Math.FloatBetween(0.001, 0.003);
this.spawnTime = scene.time.now;
this.spawnX = x;
}
}
preUpdate(time, delta) {
super.preUpdate(time, delta);
if (this.enemyType === 'bug') {
this.x += this.speed * (delta / 1000);
if (Math.abs(this.x - this.startX) > this.patrolRange) {
this.speed *= -1;
this.setFlipX(this.speed < 0);
}
// Wrap
if (this.x < -60) this.x = GAME_WIDTH + 60;
if (this.x > GAME_WIDTH + 60) this.x = -60;
} else if (this.enemyType === 'failed_tx') {
const dt = delta / 1000;
this.y += this.fallSpeed * dt;
const t = time - this.spawnTime;
this.x = this.spawnX + Math.sin(t * this.driftFreq) * this.driftAmplitude;
this.x = Phaser.Math.Clamp(this.x, 30, GAME_WIDTH - 30);
this.setAngle(Math.sin(t * 0.005) * 15);
}
}
}

View File

@@ -0,0 +1,149 @@
import { sound } from './SoundManager.js';
const STORAGE_KEY = 'naddie_achievements_v1';
const DEFS = [
{ id: 'genesis_block', title: 'Genesis Block', desc: 'Land on a gold platform' },
{ id: 'chain_reaction', title: 'Chain Reaction', desc: 'Reach combo x3.0' },
{ id: 'bug_hunter', title: 'Bug Hunter', desc: 'Stomp 5 enemies in one run' },
{ id: 'first_flight', title: 'First Flight', desc: 'Use the propeller' },
{ id: 'liftoff', title: 'Liftoff', desc: 'Use the rocket' },
{ id: 'power_trip', title: 'Power Trip', desc: 'Use all 3 power-ups in one run' },
{ id: 'survivor', title: 'Survivor', desc: 'Reach block height 500' },
{ id: 'skyscraper', title: 'Skyscraper', desc: 'Reach block height 1000' },
{ id: 'speedrun', title: 'Speedrun', desc: 'Reach block 100 in under 60s' },
{ id: 'gas_baron', title: 'Gas Baron', desc: 'Score 50,000 in one run' },
];
export class AchievementsManager {
constructor(scene) {
this.scene = scene;
this.unlocked = this._load();
this.popupQueue = [];
this.popupActive = false;
// Per-run counters
this.bugsStomped = 0;
this.powerupsUsed = new Set();
this.startTime = Date.now();
}
static getAll() {
return DEFS;
}
_load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch (_) {
return {};
}
}
_save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.unlocked));
} catch (_) {}
}
isUnlocked(id) {
return !!this.unlocked[id];
}
unlock(id) {
if (this.unlocked[id]) return;
const def = DEFS.find((d) => d.id === id);
if (!def) return;
this.unlocked[id] = Date.now();
this._save();
this.popupQueue.push(def);
this._processQueue();
sound.powerup();
}
_processQueue() {
if (this.popupActive || this.popupQueue.length === 0) return;
this.popupActive = true;
const def = this.popupQueue.shift();
this._showPopup(def, () => {
this.popupActive = false;
this._processQueue();
});
}
_showPopup(def, onDone) {
const { width } = this.scene.scale;
const y = 110;
const panel = this.scene.add.rectangle(width / 2, y, 320, 70, 0x1a0533, 0.92)
.setStrokeStyle(2, 0xffd700)
.setScrollFactor(0)
.setDepth(800);
const title = this.scene.add.text(width / 2, y - 14, `ACHIEVEMENT: ${def.title}`, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '10px',
color: '#ffd700',
}).setOrigin(0.5).setScrollFactor(0).setDepth(801);
const desc = this.scene.add.text(width / 2, y + 10, def.desc, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '8px',
color: '#d8b4fe',
}).setOrigin(0.5).setScrollFactor(0).setDepth(801);
panel.setAlpha(0);
title.setAlpha(0);
desc.setAlpha(0);
this.scene.tweens.add({
targets: [panel, title, desc],
alpha: 1,
y: `+=10`,
duration: 250,
ease: 'Quad.easeOut',
});
this.scene.time.delayedCall(2500, () => {
this.scene.tweens.add({
targets: [panel, title, desc],
alpha: 0,
duration: 350,
onComplete: () => {
panel.destroy(); title.destroy(); desc.destroy();
onDone();
},
});
});
}
// --- Game event hooks ---
onPlatformLand(type, combo) {
if (type === 'genesis') this.unlock('genesis_block');
if (combo >= 3) this.unlock('chain_reaction');
}
onEnemyStomp() {
this.bugsStomped += 1;
if (this.bugsStomped >= 5) this.unlock('bug_hunter');
}
onPowerup(name) {
this.powerupsUsed.add(name);
if (name === 'propeller') this.unlock('first_flight');
if (name === 'rocket') this.unlock('liftoff');
if (this.powerupsUsed.size >= 3) this.unlock('power_trip');
}
onBlockHeight(blocks) {
if (blocks >= 500) this.unlock('survivor');
if (blocks >= 1000) this.unlock('skyscraper');
if (blocks >= 100) {
const elapsed = (Date.now() - this.startTime) / 1000;
if (elapsed <= 60) this.unlock('speedrun');
}
}
onScore(score) {
if (score >= 50000) this.unlock('gas_baron');
}
}

View File

@@ -92,8 +92,12 @@ export class PlatformManager {
Math.floor(difficultyLevel / 1000) * DIFFICULTY.enemyIncreasePer1000,
DIFFICULTY.maxEnemyRate
);
const bugRate = Math.min(ENEMY_RATES.bug + enemyBonus, DIFFICULTY.maxEnemyRate);
if (Math.random() >= bugRate) return;
const totalRate = Math.min(ENEMY_RATES.bug + enemyBonus, DIFFICULTY.maxEnemyRate);
if (Math.random() >= totalRate) return;
// Failed-Tx type unlocks at higher difficulty
const failedTxAllowed = difficultyLevel > 800;
const type = failedTxAllowed && Math.random() < 0.30 ? 'failed_tx' : 'bug';
const offset = Phaser.Math.Between(-60, 60);
const ex = Phaser.Math.Clamp(platformX + offset, 50, GAME_WIDTH - 50);
@@ -108,7 +112,7 @@ export class PlatformManager {
});
if (tooClose) return;
this.enemies.add(new Enemy(this.scene, ex, ey, 'bug'));
this.enemies.add(new Enemy(this.scene, ex, ey, type));
}
getPlatforms() {

View File

@@ -39,6 +39,18 @@ export class ScoreManager {
color: '#aaa',
}).setOrigin(1, 0).setScrollFactor(0).setDepth(200).setShadow(1, 1, '#000000', 2, false, true);
// Powerup duration bar (hidden until active)
this.powerupBarBg = scene.add.rectangle(scene.scale.width / 2, scene.scale.height - 24, 200, 12, 0x000000, 0.6)
.setStrokeStyle(2, 0xa855f7)
.setScrollFactor(0).setDepth(200).setVisible(false);
this.powerupBarFill = scene.add.rectangle(scene.scale.width / 2 - 99, scene.scale.height - 24, 196, 8, 0xa855f7)
.setOrigin(0, 0.5).setScrollFactor(0).setDepth(201).setVisible(false);
this.powerupBarLabel = scene.add.text(scene.scale.width / 2, scene.scale.height - 40, '', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '9px',
color: '#d8b4fe',
}).setOrigin(0.5).setScrollFactor(0).setDepth(201).setVisible(false);
this.updateBestDisplay();
}
@@ -47,11 +59,51 @@ export class ScoreManager {
if (blocks > this.blockHeight) {
this.blockHeight = blocks;
this.hudBlocks.setText(`Block: ${this.blockHeight}`);
if (this.scene.achievements) this.scene.achievements.onBlockHeight(this.blockHeight);
if (this.blockHeight % 100 === 0 && this.blockHeight > 0) {
this.showMilestone(this.blockHeight);
}
}
this._updatePowerupBar();
}
_updatePowerupBar() {
const player = this.scene.player;
if (!player) return;
let active = null;
let remaining = 0;
let total = 0;
let color = 0xa855f7;
let label = '';
if (player.state === 'propeller') {
active = 'propeller';
remaining = Math.max(0, player.propellerTimer);
total = 3500;
color = 0x44aaff;
label = 'PROPELLER';
} else if (player.state === 'rocket') {
active = 'rocket';
remaining = Math.max(0, player.rocketTimer);
total = 3000;
color = 0xff4444;
label = 'ROCKET';
}
if (active) {
const pct = remaining / total;
this.powerupBarBg.setVisible(true);
this.powerupBarFill.setVisible(true).setFillStyle(color);
this.powerupBarFill.scaleX = pct;
this.powerupBarLabel.setVisible(true).setText(label);
} else {
this.powerupBarBg.setVisible(false);
this.powerupBarFill.setVisible(false);
this.powerupBarLabel.setVisible(false);
}
}
onLand(platformY, platformType) {
@@ -72,7 +124,13 @@ export class ScoreManager {
if (this.genesisJumps <= 0) this.genesisActive = false;
}
this.addPoints(Math.floor(basePoints * multiplier));
const gained = Math.floor(basePoints * multiplier);
this.addPoints(gained);
this._spawnScorePopup(gained, platformType === 'genesis');
if (this.scene.achievements) {
this.scene.achievements.onPlatformLand(platformType, this.comboMultiplier);
}
if (this.combo > 1) {
this.hudCombo.setText(`Combo x${this.comboMultiplier.toFixed(1)}`);
@@ -80,16 +138,37 @@ export class ScoreManager {
}
}
_spawnScorePopup(amount, big) {
const player = this.scene.player;
if (!player) return;
const txt = this.scene.add.text(player.x, player.y - 30, `+${amount}`, {
fontFamily: '"Press Start 2P", monospace',
fontSize: big ? '14px' : '10px',
color: big ? '#ffd700' : '#22c55e',
}).setOrigin(0.5).setDepth(150);
this.scene.tweens.add({
targets: txt,
y: txt.y - 35,
alpha: 0,
duration: 700,
ease: 'Quad.easeOut',
onComplete: () => txt.destroy(),
});
}
addPoints(amount) {
this.score += amount;
this.hudScore.setText(`Gas: ${this.score}`);
if (this.scene.achievements) this.scene.achievements.onScore(this.score);
}
onFall() {
if (this.combo > 0) {
this.combo = 0;
this.comboMultiplier = 1;
this.hudCombo.setText('');
}
}
showMilestone(blocks) {
const { width, height } = this.scene.scale;
@@ -133,5 +212,8 @@ export class ScoreManager {
this.hudBlocks.destroy();
this.hudCombo.destroy();
this.hudBest.destroy();
this.powerupBarBg.destroy();
this.powerupBarFill.destroy();
this.powerupBarLabel.destroy();
}
}

View File

@@ -3,6 +3,7 @@ import { Player } from '../entities/Player.js';
import { Platform } from '../entities/Platform.js';
import { PlatformManager } from '../managers/PlatformManager.js';
import { ScoreManager } from '../managers/ScoreManager.js';
import { AchievementsManager } from '../managers/AchievementsManager.js';
import { sound } from '../managers/SoundManager.js';
import { GAME_WIDTH, GAME_HEIGHT, SCORE, PHYSICS } from '../config/game.config.js';
@@ -19,6 +20,10 @@ export class GameScene extends Scene {
.setScrollFactor(0)
.setDepth(-10);
// Color overlay for height-based tint shift
this.bgTint = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000033, 0)
.setScrollFactor(0).setDepth(-9);
this.createGweiParticles();
this.cursors = this.input.keyboard.createCursorKeys();
@@ -42,6 +47,7 @@ export class GameScene extends Scene {
this.trailGraphics = this.add.graphics().setDepth(-3);
this.platformManager = new PlatformManager(this);
this.achievements = new AchievementsManager(this);
this.scoreManager = new ScoreManager(this);
for (let i = 0; i < 10; i++) {
@@ -84,6 +90,10 @@ export class GameScene extends Scene {
const height = Math.max(0, GAME_HEIGHT - this.player.y);
this.difficultyLevel = height;
// Background darkens as player climbs higher (cosmic feel)
const tintAlpha = Math.min(0.5, height / 8000);
this.bgTint.fillAlpha = tintAlpha;
this.platformManager.update(this.difficultyLevel, killLine);
this.scoreManager.update(this.player.y);
@@ -160,13 +170,18 @@ export class GameScene extends Scene {
if (powerup && typeof powerup.onPlayerTouch === 'function') {
const px = powerup.x;
const py = powerup.y;
const isSpring = powerup.constructor.name === 'Spring';
const name = powerup.constructor.name.toLowerCase();
const isSpring = name === 'spring';
const consumed = powerup.onPlayerTouch(player);
if (consumed) {
this.createPowerupParticles(px, py);
this.flashScreen();
if (isSpring) sound.spring();
else sound.powerup();
if (this.achievements) {
const map = { spring: 'spring', propellerhat: 'propeller', rocket: 'rocket' };
this.achievements.onPowerup(map[name] || name);
}
}
}
}
@@ -192,6 +207,7 @@ export class GameScene extends Scene {
player.jump();
sound.stomp();
this.scoreManager.addPoints(SCORE.stompBonus);
if (this.achievements) this.achievements.onEnemyStomp();
this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3);
return;
}