957 lines
27 KiB
JavaScript
957 lines
27 KiB
JavaScript
/**
|
|
* GameScene.js
|
|
* Main battle scene - orchestrates map, tanks, bullets, power-ups, and HUD.
|
|
* This is the core gameplay scene that integrates all sub-systems.
|
|
*/
|
|
|
|
const {
|
|
SCREEN_WIDTH,
|
|
SCREEN_HEIGHT,
|
|
COLORS,
|
|
SCENE,
|
|
GAME_MODE,
|
|
MAP_OFFSET_X,
|
|
MAP_OFFSET_Y,
|
|
MAP_WIDTH,
|
|
MAP_HEIGHT,
|
|
TILE_SIZE,
|
|
GRID_COLS,
|
|
GRID_ROWS,
|
|
DIRECTION,
|
|
DIR_VECTORS,
|
|
BULLET_SPEED,
|
|
FIRE_LEVEL,
|
|
POWERUP_TYPE,
|
|
FREEZE_DURATION,
|
|
SHIELD_DURATION,
|
|
SHOVEL_DURATION,
|
|
TANK_TYPE,
|
|
TERRAIN,
|
|
} = require('../base/GameGlobal');
|
|
|
|
const ObjectPool = require('../base/ObjectPool');
|
|
const MapManager = require('../managers/MapManager');
|
|
const CollisionManager = require('../managers/CollisionManager');
|
|
const SpawnManager = require('../managers/SpawnManager');
|
|
const PlayerTank = require('../entities/PlayerTank');
|
|
const Bullet = require('../entities/Bullet');
|
|
const Explosion = require('../entities/Explosion');
|
|
const PowerUp = require('../entities/PowerUp');
|
|
const Joystick = require('../ui/Joystick');
|
|
const FireButton = require('../ui/FireButton');
|
|
const { getLevelData } = require('../data/LevelData');
|
|
const { t } = require('../i18n/I18n');
|
|
|
|
const GameScene = {
|
|
_mode: GAME_MODE.CLASSIC,
|
|
_level: 1,
|
|
_initialized: false,
|
|
_gameOver: false,
|
|
_victory: false,
|
|
_paused: false,
|
|
|
|
// Sub-systems
|
|
_mapManager: null,
|
|
_collisionManager: null,
|
|
_spawnManager: null,
|
|
_playerTank: null,
|
|
_joystick: null,
|
|
_fireButton: null,
|
|
|
|
// Entity lists
|
|
_enemies: [],
|
|
_bullets: [],
|
|
_explosions: [],
|
|
_powerUps: [],
|
|
|
|
// Object pools
|
|
_bulletPool: null,
|
|
_explosionPool: null,
|
|
|
|
// Game stats
|
|
_stats: null,
|
|
_levelStartTime: 0,
|
|
_freezeTimer: 0,
|
|
|
|
// Game over delay
|
|
_gameOverDelay: 0,
|
|
_gameOverDelayDuration: 2, // seconds
|
|
|
|
// Revive ad state
|
|
_reviveAdUsed: false,
|
|
_reviveCount: 0, // Track revive count for escalating cost
|
|
_showingReviveDialog: false,
|
|
_reviveDialogButtons: null,
|
|
|
|
// Buff manager reference
|
|
_buffManager: null,
|
|
|
|
|
|
|
|
enter(params) {
|
|
this._mode = (params && params.mode) || GAME_MODE.CLASSIC;
|
|
this._level = (params && params.level) || 1;
|
|
this._gameOver = false;
|
|
this._victory = false;
|
|
this._paused = false;
|
|
this._freezeTimer = 0;
|
|
this._gameOverDelay = 0;
|
|
this._cachedBasePos = null;
|
|
this._reviveAdUsed = false;
|
|
this._reviveCount = 0;
|
|
this._showingReviveDialog = false;
|
|
this._reviveDialogButtons = null;
|
|
|
|
// Initialize buff manager reference
|
|
this._buffManager = GameGlobal.buffManager || null;
|
|
|
|
// Initialize stats
|
|
this._stats = {
|
|
kills: { normal: 0, fast: 0, armor: 0, boss: 0 },
|
|
totalKills: 0,
|
|
score: 0,
|
|
timeElapsed: 0,
|
|
baseAlive: true,
|
|
};
|
|
|
|
// Initialize object pools
|
|
this._bulletPool = new ObjectPool(() => new Bullet(), null, 20);
|
|
this._explosionPool = new ObjectPool(() => new Explosion(), null, 10);
|
|
|
|
// Initialize entity lists
|
|
this._enemies = [];
|
|
this._bullets = [];
|
|
this._explosions = [];
|
|
this._powerUps = [];
|
|
|
|
// Initialize map
|
|
this._mapManager = new MapManager();
|
|
const levelData = getLevelData(this._level);
|
|
this._mapManager.loadGrid(levelData.grid);
|
|
|
|
// Initialize spawn manager
|
|
this._spawnManager = new SpawnManager();
|
|
this._spawnManager.init(levelData);
|
|
|
|
// Initialize player
|
|
this._playerTank = new PlayerTank({
|
|
col: levelData.playerSpawn.col,
|
|
row: levelData.playerSpawn.row,
|
|
});
|
|
this._playerTank.activateShield(3000); // spawn protection
|
|
|
|
// Apply equipped skin colors to player tank
|
|
if (GameGlobal.skinManager) {
|
|
this._playerTank._skinColors = GameGlobal.skinManager.getCurrentSkinColors();
|
|
this._playerTank._skinId = GameGlobal.skinManager.getEquippedSkinId();
|
|
}
|
|
|
|
// Safety: ensure player spawn area is clear of blocking terrain
|
|
this._clearSpawnArea(levelData.playerSpawn.col, levelData.playerSpawn.row);
|
|
|
|
// Initialize collision manager
|
|
this._collisionManager = new CollisionManager({
|
|
mapManager: this._mapManager,
|
|
onExplosion: (x, y, isBig) => this._spawnExplosion(x, y, isBig),
|
|
eventBus: GameGlobal.eventBus,
|
|
});
|
|
|
|
// Initialize controls
|
|
this._joystick = new Joystick();
|
|
this._fireButton = new FireButton();
|
|
this._fireButton.onFire(() => this._playerFire());
|
|
|
|
// Event listeners
|
|
this._setupEvents();
|
|
|
|
this._levelStartTime = Date.now();
|
|
this._initialized = true;
|
|
|
|
// Activate pre-game buffs if any were purchased
|
|
if (this._buffManager) {
|
|
this._buffManager.activateBuffs(this._playerTank);
|
|
}
|
|
|
|
// Preload rewarded video ad for revive/double reward scenes
|
|
if (GameGlobal.adManager) {
|
|
GameGlobal.adManager.preloadRewardedVideo();
|
|
}
|
|
|
|
console.log(`[GameScene] Level ${this._level} started. Mode: ${this._mode}`);
|
|
},
|
|
|
|
exit() {
|
|
this._initialized = false;
|
|
this._cleanupEvents();
|
|
this._bullets = [];
|
|
this._enemies = [];
|
|
this._explosions = [];
|
|
this._powerUps = [];
|
|
|
|
// Clear buffs at end of round
|
|
if (this._buffManager) {
|
|
this._buffManager.clearBuffs();
|
|
}
|
|
},
|
|
|
|
_setupEvents() {
|
|
const eb = GameGlobal.eventBus;
|
|
this._onEnemyDestroyed = (data) => this._handleEnemyDestroyed(data);
|
|
this._onPlayerDestroyed = () => this._handlePlayerDestroyed();
|
|
this._onBaseDestroyed = () => this._handleBaseDestroyed();
|
|
|
|
eb.on('enemy:destroyed', this._onEnemyDestroyed);
|
|
eb.on('player:destroyed', this._onPlayerDestroyed);
|
|
eb.on('base:destroyed', this._onBaseDestroyed);
|
|
},
|
|
|
|
_cleanupEvents() {
|
|
const eb = GameGlobal.eventBus;
|
|
eb.off('enemy:destroyed', this._onEnemyDestroyed);
|
|
eb.off('player:destroyed', this._onPlayerDestroyed);
|
|
eb.off('base:destroyed', this._onBaseDestroyed);
|
|
},
|
|
|
|
// ============================================================
|
|
// Update
|
|
// ============================================================
|
|
update(dt) {
|
|
if (!this._initialized || this._paused) return;
|
|
|
|
// Game over delay (show explosion before transitioning)
|
|
if (this._gameOver || this._victory) {
|
|
this._gameOverDelay += dt;
|
|
// Still update explosions during delay
|
|
this._updateExplosions(dt);
|
|
if (this._gameOverDelay >= this._gameOverDelayDuration) {
|
|
this._transitionToResult();
|
|
}
|
|
return;
|
|
}
|
|
|
|
this._stats.timeElapsed += dt;
|
|
|
|
// Update map (shovel timer etc.)
|
|
this._mapManager.update(dt);
|
|
|
|
// Update freeze timer
|
|
if (this._freezeTimer > 0) {
|
|
this._freezeTimer -= dt * 1000;
|
|
if (this._freezeTimer < 0) this._freezeTimer = 0;
|
|
}
|
|
|
|
// Player movement
|
|
if (this._playerTank.alive && this._joystick.active && this._joystick.direction >= 0) {
|
|
this._playerTank.move(this._joystick.direction, dt, this._mapManager);
|
|
}
|
|
this._playerTank.update(dt);
|
|
|
|
// Update buff timers
|
|
if (this._buffManager) {
|
|
this._buffManager.update(dt, this._playerTank);
|
|
}
|
|
|
|
// Spawn enemies (pause spawning while freeze is active)
|
|
if (this._freezeTimer <= 0) {
|
|
const newEnemy = this._spawnManager.update(dt, this._enemies);
|
|
if (newEnemy) {
|
|
this._enemies.push(newEnemy);
|
|
}
|
|
}
|
|
|
|
// Update enemies — find base position from the grid
|
|
const basePos = this._findBasePos();
|
|
for (const enemy of this._enemies) {
|
|
if (this._freezeTimer > 0) {
|
|
enemy.frozen = true;
|
|
} else {
|
|
enemy.frozen = false;
|
|
}
|
|
enemy.update(dt, this._mapManager, basePos, (tank) => this._enemyFire(tank));
|
|
}
|
|
|
|
// Update bullets (freeze enemy bullets while freeze is active)
|
|
for (const bullet of this._bullets) {
|
|
if (this._freezeTimer > 0 && bullet.owner === 'enemy') {
|
|
continue; // enemy bullets are frozen
|
|
}
|
|
bullet.update(dt);
|
|
}
|
|
|
|
// Update power-ups
|
|
for (const pu of this._powerUps) {
|
|
pu.update(dt);
|
|
}
|
|
|
|
// Collision detection
|
|
this._collisionManager.update({
|
|
player: this._playerTank,
|
|
enemies: this._enemies,
|
|
bullets: this._bullets,
|
|
});
|
|
|
|
// Check power-up pickup
|
|
this._checkPowerUpPickup();
|
|
|
|
// Update explosions
|
|
this._updateExplosions(dt);
|
|
|
|
// Cleanup dead entities
|
|
this._cleanup();
|
|
|
|
// Check victory condition
|
|
this._checkVictory();
|
|
},
|
|
|
|
// ============================================================
|
|
// Render
|
|
// ============================================================
|
|
render(ctx) {
|
|
if (!this._initialized) return;
|
|
|
|
// Draw game area background
|
|
ctx.fillStyle = '#111111';
|
|
ctx.fillRect(MAP_OFFSET_X, MAP_OFFSET_Y, MAP_WIDTH, MAP_HEIGHT);
|
|
|
|
// Render map (terrain layer)
|
|
this._mapManager.render(ctx);
|
|
|
|
// Render power-ups
|
|
for (const pu of this._powerUps) {
|
|
pu.render(ctx);
|
|
}
|
|
|
|
// Render player
|
|
this._playerTank.render(ctx);
|
|
|
|
// Render enemies
|
|
for (const enemy of this._enemies) {
|
|
enemy.render(ctx);
|
|
}
|
|
|
|
// Render forest overlay (on top of tanks)
|
|
this._mapManager.renderForestOverlay(ctx);
|
|
|
|
// Render bullets
|
|
for (const bullet of this._bullets) {
|
|
bullet.render(ctx);
|
|
}
|
|
|
|
// Render explosions
|
|
for (const exp of this._explosions) {
|
|
exp.render(ctx);
|
|
}
|
|
|
|
// Render HUD
|
|
this._renderHUD(ctx);
|
|
|
|
// Render controls
|
|
this._joystick.render(ctx);
|
|
this._fireButton.render(ctx);
|
|
|
|
// Render pause overlay
|
|
if (this._paused && !this._showingReviveDialog) {
|
|
this._renderPauseOverlay(ctx);
|
|
}
|
|
|
|
// Render revive ad dialog
|
|
if (this._showingReviveDialog) {
|
|
this._renderReviveDialog(ctx);
|
|
}
|
|
|
|
// Game over text
|
|
if (this._gameOver) {
|
|
this._renderGameOverText(ctx, t('game.gameOver'));
|
|
} else if (this._victory) {
|
|
this._renderGameOverText(ctx, t('game.stageClear'));
|
|
}
|
|
},
|
|
|
|
// ============================================================
|
|
// HUD Rendering
|
|
// ============================================================
|
|
_renderHUD(ctx) {
|
|
// In landscape mode, HUD is rendered on the sides of the map
|
|
const leftX = MAP_OFFSET_X - 8; // right edge of left panel
|
|
const rightX = MAP_OFFSET_X + MAP_WIDTH + 8; // left edge of right panel
|
|
const topY = MAP_OFFSET_Y + 10;
|
|
|
|
// === Left side panel ===
|
|
ctx.fillStyle = COLORS.HUD_TEXT;
|
|
ctx.font = 'bold 12px Arial';
|
|
ctx.textAlign = 'right';
|
|
ctx.textBaseline = 'top';
|
|
|
|
// Level info
|
|
ctx.fillText(t('game.level', { level: this._level }), leftX, topY);
|
|
|
|
// Player lives
|
|
ctx.fillStyle = '#FFD700';
|
|
ctx.font = '11px Arial';
|
|
ctx.fillText(t('game.hp', { count: this._playerTank.lives }), leftX, topY + 20);
|
|
|
|
// Fire level
|
|
ctx.fillText(t('game.fireLevel', { level: this._playerTank.fireLevel }), leftX, topY + 38);
|
|
|
|
// === Right side panel ===
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'top';
|
|
|
|
// Remaining enemies
|
|
ctx.fillStyle = COLORS.HUD_TEXT;
|
|
ctx.font = 'bold 12px Arial';
|
|
const aliveEnemies = this._enemies.filter((e) => e.alive).length;
|
|
const remaining = this._spawnManager.remainingToSpawn + aliveEnemies;
|
|
ctx.fillText(t('game.enemies', { count: remaining }), rightX, topY);
|
|
|
|
// Score
|
|
ctx.fillStyle = '#FFD700';
|
|
ctx.font = '11px Arial';
|
|
ctx.fillText(t('game.score', { score: this._stats.score }), rightX, topY + 20);
|
|
},
|
|
|
|
_renderPauseOverlay(ctx) {
|
|
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
|
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
|
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = 'bold 28px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(t('common.paused'), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 40);
|
|
|
|
ctx.font = '16px Arial';
|
|
ctx.fillText(t('common.tapContinue'), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 10);
|
|
},
|
|
|
|
_renderGameOverText(ctx, text) {
|
|
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
|
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
|
|
|
ctx.fillStyle = text === t('game.gameOver') ? '#FF0000' : '#00FF00';
|
|
ctx.font = 'bold 32px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2);
|
|
},
|
|
|
|
/**
|
|
* Render the revive dialog overlay with dual options (ad + gold).
|
|
* @private
|
|
*/
|
|
_renderReviveDialog(ctx) {
|
|
const cx = SCREEN_WIDTH / 2;
|
|
const cy = SCREEN_HEIGHT / 2;
|
|
|
|
// Dim background
|
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
|
|
|
// Dialog box
|
|
const boxW = 300;
|
|
const boxH = 180;
|
|
ctx.fillStyle = 'rgba(30,30,30,0.95)';
|
|
ctx.strokeStyle = '#FFD700';
|
|
ctx.lineWidth = 2;
|
|
ctx.fillRect(cx - boxW / 2, cy - boxH / 2, boxW, boxH);
|
|
ctx.strokeRect(cx - boxW / 2, cy - boxH / 2, boxW, boxH);
|
|
|
|
// Title text
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = 'bold 18px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(t('ad.reviveTitle') || 'Revive Chance', cx, cy - 55);
|
|
|
|
// Subtitle
|
|
ctx.fillStyle = '#AAAAAA';
|
|
ctx.font = '12px Arial';
|
|
ctx.fillText(t('ad.reviveDesc') || 'Choose how to revive and continue', cx, cy - 35);
|
|
|
|
const btns = this._reviveDialogButtons;
|
|
if (btns) {
|
|
// Watch Ad button (green) - only show if ad is available
|
|
if (btns.watchAd) {
|
|
ctx.fillStyle = '#4CAF50';
|
|
ctx.fillRect(btns.watchAd.x, btns.watchAd.y, btns.watchAd.w, btns.watchAd.h);
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = 'bold 13px Arial';
|
|
ctx.fillText(t('ad.watchAd') || '📺 Watch Ad (Free)', btns.watchAd.x + btns.watchAd.w / 2, btns.watchAd.y + btns.watchAd.h / 2);
|
|
}
|
|
|
|
// Gold Revive button (orange) - show escalating cost
|
|
if (btns.goldRevive) {
|
|
const reviveCost = this._getReviveCost();
|
|
const hasGold = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(reviveCost);
|
|
ctx.fillStyle = hasGold ? '#FF9800' : '#555555';
|
|
ctx.fillRect(btns.goldRevive.x, btns.goldRevive.y, btns.goldRevive.w, btns.goldRevive.h);
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = 'bold 13px Arial';
|
|
ctx.fillText(`🪙 ${t('ad.goldRevive') || 'Gold Revive'} (${reviveCost})`, btns.goldRevive.x + btns.goldRevive.w / 2, btns.goldRevive.y + btns.goldRevive.h / 2);
|
|
}
|
|
|
|
// Give Up button (gray)
|
|
ctx.fillStyle = '#666666';
|
|
ctx.fillRect(btns.giveUp.x, btns.giveUp.y, btns.giveUp.w, btns.giveUp.h);
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = 'bold 13px Arial';
|
|
ctx.fillText(t('ad.giveUp') || 'Give Up', btns.giveUp.x + btns.giveUp.w / 2, btns.giveUp.y + btns.giveUp.h / 2);
|
|
}
|
|
},
|
|
|
|
// ============================================================
|
|
// Game Logic
|
|
// ============================================================
|
|
_playerFire() {
|
|
if (!this._playerTank.alive || !this._playerTank.canFire()) return;
|
|
if (this._gameOver || this._victory || this._paused) return;
|
|
|
|
const tank = this._playerTank;
|
|
const vec = DIR_VECTORS[tank.direction];
|
|
const bullet = this._bulletPool.get();
|
|
bullet.init({
|
|
x: tank.x + vec.dx * tank.halfSize,
|
|
y: tank.y + vec.dy * tank.halfSize,
|
|
direction: tank.direction,
|
|
owner: 'player',
|
|
canBreakSteel: tank.canBreakSteel(),
|
|
ownerTank: tank,
|
|
});
|
|
tank.activeBullets++;
|
|
this._bullets.push(bullet);
|
|
GameGlobal.audioManager.playSFX('shoot');
|
|
},
|
|
|
|
_enemyFire(enemyTank) {
|
|
if (!enemyTank.alive || !enemyTank.canFire()) return;
|
|
|
|
const vec = DIR_VECTORS[enemyTank.direction];
|
|
const bullet = this._bulletPool.get();
|
|
bullet.init({
|
|
x: enemyTank.x + vec.dx * enemyTank.halfSize,
|
|
y: enemyTank.y + vec.dy * enemyTank.halfSize,
|
|
direction: enemyTank.direction,
|
|
owner: 'enemy',
|
|
canBreakSteel: false,
|
|
ownerTank: enemyTank,
|
|
});
|
|
enemyTank.activeBullets++;
|
|
this._bullets.push(bullet);
|
|
GameGlobal.audioManager.playSFX('shoot');
|
|
},
|
|
|
|
_spawnExplosion(x, y, isBig) {
|
|
const exp = this._explosionPool.get();
|
|
exp.init(x, y, isBig);
|
|
this._explosions.push(exp);
|
|
GameGlobal.audioManager.playSFX(isBig ? 'explosion_big' : 'explosion_small');
|
|
},
|
|
|
|
/**
|
|
* Clear any blocking terrain at the player spawn area.
|
|
* Ensures the tank won't be stuck inside walls on spawn.
|
|
* @private
|
|
*/
|
|
_clearSpawnArea(col, row) {
|
|
const terrain = this._mapManager.getTerrain(row, col);
|
|
if (terrain !== TERRAIN.EMPTY && terrain !== TERRAIN.FOREST) {
|
|
this._mapManager.setTerrain(row, col, TERRAIN.EMPTY);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Find the base (eagle) position from the map grid.
|
|
* Scans for TERRAIN.BASE and returns its pixel center.
|
|
* Result is cached after first call per level.
|
|
* @private
|
|
* @returns {{x: number, y: number}}
|
|
*/
|
|
_findBasePos() {
|
|
if (this._cachedBasePos) return this._cachedBasePos;
|
|
// Scan grid for BASE terrain
|
|
for (let r = GRID_ROWS - 1; r >= 0; r--) {
|
|
for (let c = 0; c < GRID_COLS; c++) {
|
|
if (this._mapManager.getTerrain(r, c) === TERRAIN.BASE) {
|
|
this._cachedBasePos = {
|
|
x: MAP_OFFSET_X + c * TILE_SIZE + TILE_SIZE / 2,
|
|
y: MAP_OFFSET_Y + r * TILE_SIZE + TILE_SIZE / 2,
|
|
};
|
|
return this._cachedBasePos;
|
|
}
|
|
}
|
|
}
|
|
// Fallback: center-bottom
|
|
this._cachedBasePos = {
|
|
x: MAP_OFFSET_X + Math.floor(GRID_COLS / 2) * TILE_SIZE + TILE_SIZE / 2,
|
|
y: MAP_OFFSET_Y + (GRID_ROWS - 1) * TILE_SIZE + TILE_SIZE / 2,
|
|
};
|
|
return this._cachedBasePos;
|
|
},
|
|
|
|
_updateExplosions(dt) {
|
|
for (const exp of this._explosions) {
|
|
exp.update(dt);
|
|
}
|
|
},
|
|
|
|
_checkPowerUpPickup() {
|
|
if (!this._playerTank.alive) return;
|
|
|
|
const pb = this._playerTank.getBounds();
|
|
for (const pu of this._powerUps) {
|
|
if (!pu.alive) continue;
|
|
const pub = pu.getBounds();
|
|
|
|
if (
|
|
pb.x < pub.x + pub.w &&
|
|
pb.x + pb.w > pub.x &&
|
|
pb.y < pub.y + pub.h &&
|
|
pb.y + pb.h > pub.y
|
|
) {
|
|
this._applyPowerUp(pu);
|
|
pu.alive = false;
|
|
GameGlobal.audioManager.playSFX('powerup');
|
|
}
|
|
}
|
|
},
|
|
|
|
_applyPowerUp(powerUp) {
|
|
switch (powerUp.type) {
|
|
case POWERUP_TYPE.STAR:
|
|
this._playerTank.upgradeFireLevel();
|
|
break;
|
|
case POWERUP_TYPE.CLOCK:
|
|
this._freezeTimer = FREEZE_DURATION;
|
|
break;
|
|
case POWERUP_TYPE.BOMB:
|
|
// Destroy all on-screen enemies
|
|
for (const enemy of this._enemies) {
|
|
if (enemy.alive) {
|
|
enemy.alive = false;
|
|
this._spawnExplosion(enemy.x, enemy.y, true);
|
|
this._recordKill(enemy);
|
|
}
|
|
}
|
|
break;
|
|
case POWERUP_TYPE.HELMET:
|
|
this._playerTank.activateShield(SHIELD_DURATION);
|
|
break;
|
|
case POWERUP_TYPE.SHOVEL:
|
|
this._mapManager.activateShovel(SHOVEL_DURATION);
|
|
break;
|
|
case POWERUP_TYPE.TANK:
|
|
this._playerTank.addLife();
|
|
break;
|
|
}
|
|
},
|
|
|
|
_handleEnemyDestroyed(data) {
|
|
const enemy = data.enemy;
|
|
this._recordKill(enemy);
|
|
|
|
// Spawn power-up if this enemy was marked
|
|
if (enemy.hasPowerUp) {
|
|
const type = PowerUp.randomType(this._level);
|
|
const pos = PowerUp.randomPosition(this._mapManager);
|
|
const pu = new PowerUp(type, pos.x, pos.y);
|
|
this._powerUps.push(pu);
|
|
}
|
|
},
|
|
|
|
_recordKill(enemy) {
|
|
this._stats.totalKills++;
|
|
this._stats.score += enemy.score || 100;
|
|
|
|
switch (enemy.type) {
|
|
case TANK_TYPE.ENEMY_NORMAL:
|
|
this._stats.kills.normal++;
|
|
break;
|
|
case TANK_TYPE.ENEMY_FAST:
|
|
this._stats.kills.fast++;
|
|
break;
|
|
case TANK_TYPE.ENEMY_ARMOR:
|
|
this._stats.kills.armor++;
|
|
break;
|
|
case TANK_TYPE.ENEMY_BOSS:
|
|
this._stats.kills.boss++;
|
|
break;
|
|
}
|
|
},
|
|
|
|
_handlePlayerDestroyed() {
|
|
// Check if buff shield can absorb the hit
|
|
if (this._buffManager && this._buffManager.consumeShield(this._playerTank)) {
|
|
// Shield absorbed the damage, player survives
|
|
this._playerTank.hp = 1;
|
|
this._playerTank.alive = true;
|
|
return;
|
|
}
|
|
|
|
const hasLives = this._playerTank.die();
|
|
if (!hasLives) {
|
|
// Always show revive dialog (escalating cost each time)
|
|
this._showReviveAdDialog();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Check if revive ad can be shown.
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
_canShowReviveAd() {
|
|
if (!GameGlobal.adManager) return false;
|
|
const AdManager = require('../managers/AdManager');
|
|
return GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.REVIVE);
|
|
},
|
|
|
|
/**
|
|
* Get the current revive gold cost based on revive count (escalating).
|
|
* 1st revive: 200, 2nd: 400, 3rd+: 800
|
|
* @returns {number}
|
|
* @private
|
|
*/
|
|
_getReviveCost() {
|
|
if (this._reviveCount === 0) return 200;
|
|
if (this._reviveCount === 1) return 400;
|
|
return 800;
|
|
},
|
|
|
|
/**
|
|
* Show the revive dialog overlay with dual options.
|
|
* Pauses the game and presents watch-ad / gold-revive / give-up options.
|
|
* @private
|
|
*/
|
|
_showReviveAdDialog() {
|
|
this._showingReviveDialog = true;
|
|
this._paused = true;
|
|
|
|
const cx = SCREEN_WIDTH / 2;
|
|
const cy = SCREEN_HEIGHT / 2;
|
|
const btnW = 220;
|
|
const btnH = 36;
|
|
|
|
// Check if ad is available
|
|
const canShowAd = this._canShowReviveAd();
|
|
|
|
const buttons = {
|
|
giveUp: { x: cx - btnW / 2, y: cy + 50, w: btnW, h: btnH },
|
|
};
|
|
|
|
if (canShowAd) {
|
|
buttons.watchAd = { x: cx - btnW / 2, y: cy - 20, w: btnW, h: btnH };
|
|
buttons.goldRevive = { x: cx - btnW / 2, y: cy + 15, w: btnW, h: btnH };
|
|
buttons.giveUp = { x: cx - btnW / 2, y: cy + 50, w: btnW, h: btnH };
|
|
} else {
|
|
// No ad available, only show gold revive and give up
|
|
buttons.goldRevive = { x: cx - btnW / 2, y: cy - 5, w: btnW, h: btnH };
|
|
buttons.giveUp = { x: cx - btnW / 2, y: cy + 35, w: btnW, h: btnH };
|
|
}
|
|
|
|
this._reviveDialogButtons = buttons;
|
|
},
|
|
|
|
/**
|
|
* Handle the player choosing to revive with gold (escalating cost).
|
|
* @private
|
|
*/
|
|
_onGoldRevive() {
|
|
const cost = this._getReviveCost();
|
|
const cm = GameGlobal.currencyManager;
|
|
if (cm && cm.spendGold(cost)) {
|
|
this._showingReviveDialog = false;
|
|
this._reviveDialogButtons = null;
|
|
this._reviveCount++;
|
|
this._revivePlayer();
|
|
console.log(`[GameScene] Player revived via gold (${cost}), revive #${this._reviveCount}`);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Handle the player choosing to watch the revive ad.
|
|
* @private
|
|
*/
|
|
_onReviveAdWatch() {
|
|
const AdManager = require('../managers/AdManager');
|
|
GameGlobal.adManager.showRewardedVideoForScene(
|
|
AdManager.AD_SCENE.REVIVE,
|
|
(completed) => {
|
|
this._showingReviveDialog = false;
|
|
this._reviveDialogButtons = null;
|
|
if (completed) {
|
|
this._reviveCount++;
|
|
this._revivePlayer();
|
|
} else {
|
|
this._triggerGameOver();
|
|
}
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Handle the player choosing to give up (skip revive ad).
|
|
* @private
|
|
*/
|
|
_onReviveAdGiveUp() {
|
|
this._showingReviveDialog = false;
|
|
this._reviveDialogButtons = null;
|
|
this._triggerGameOver();
|
|
},
|
|
|
|
/**
|
|
* Revive the player: restore 1 life, keep fire level, respawn at start.
|
|
* @private
|
|
*/
|
|
_revivePlayer() {
|
|
this._paused = false;
|
|
this._playerTank.addLife();
|
|
// Respawn: reset position, alive=true, hp=1, shield protection
|
|
this._playerTank.respawn();
|
|
console.log('[GameScene] Player revived');
|
|
},
|
|
|
|
/**
|
|
* Trigger the game over state.
|
|
* @private
|
|
*/
|
|
_triggerGameOver() {
|
|
this._paused = false;
|
|
this._gameOver = true;
|
|
this._stats.baseAlive = !this._mapManager.baseDestroyed;
|
|
GameGlobal.audioManager.playSFX('gameover');
|
|
},
|
|
|
|
_handleBaseDestroyed() {
|
|
this._gameOver = true;
|
|
this._stats.baseAlive = false;
|
|
GameGlobal.audioManager.playSFX('gameover');
|
|
},
|
|
|
|
_checkVictory() {
|
|
if (this._gameOver || this._victory) return;
|
|
|
|
const allSpawned = this._spawnManager.allSpawned;
|
|
const allDead = this._enemies.every((e) => !e.alive);
|
|
|
|
if (allSpawned && allDead) {
|
|
this._victory = true;
|
|
this._stats.baseAlive = !this._mapManager.baseDestroyed;
|
|
GameGlobal.audioManager.playSFX('victory');
|
|
|
|
// Time bonus
|
|
const timeBonus = Math.max(0, 300 - Math.floor(this._stats.timeElapsed)) * 10;
|
|
this._stats.score += timeBonus;
|
|
|
|
// Base alive bonus
|
|
if (this._stats.baseAlive) {
|
|
this._stats.score += 1000;
|
|
}
|
|
}
|
|
},
|
|
|
|
_cleanup() {
|
|
// Recycle dead bullets
|
|
for (let i = this._bullets.length - 1; i >= 0; i--) {
|
|
if (!this._bullets[i].alive) {
|
|
this._bulletPool.put(this._bullets[i]);
|
|
this._bullets.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
// Recycle dead explosions
|
|
for (let i = this._explosions.length - 1; i >= 0; i--) {
|
|
if (!this._explosions[i].alive) {
|
|
this._explosionPool.put(this._explosions[i]);
|
|
this._explosions.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
// Remove dead power-ups
|
|
this._powerUps = this._powerUps.filter((pu) => pu.alive);
|
|
|
|
// Remove dead enemies (keep for counting)
|
|
// Don't remove - they're needed for allDead check
|
|
},
|
|
|
|
_transitionToResult() {
|
|
const sm = GameGlobal.sceneManager;
|
|
if (!sm._scenes.has(SCENE.RESULT)) {
|
|
const ResultScene = require('./ResultScene');
|
|
sm.register(SCENE.RESULT, ResultScene);
|
|
}
|
|
sm.switchTo(SCENE.RESULT, {
|
|
level: this._level,
|
|
mode: this._mode,
|
|
victory: this._victory,
|
|
stats: this._stats,
|
|
});
|
|
},
|
|
|
|
// ============================================================
|
|
// Touch Handling
|
|
// ============================================================
|
|
handleTouch(eventType, e) {
|
|
if (this._gameOver || this._victory) return;
|
|
|
|
// Handle revive dialog touches
|
|
if (this._showingReviveDialog && eventType === 'touchstart') {
|
|
const touches = e.touches;
|
|
for (let i = 0; i < touches.length; i++) {
|
|
const tx = touches[i].clientX;
|
|
const ty = touches[i].clientY;
|
|
const btns = this._reviveDialogButtons;
|
|
if (btns) {
|
|
// Watch Ad button
|
|
if (btns.watchAd && tx >= btns.watchAd.x && tx <= btns.watchAd.x + btns.watchAd.w &&
|
|
ty >= btns.watchAd.y && ty <= btns.watchAd.y + btns.watchAd.h) {
|
|
this._onReviveAdWatch();
|
|
return;
|
|
}
|
|
// Gold Revive button
|
|
if (btns.goldRevive && tx >= btns.goldRevive.x && tx <= btns.goldRevive.x + btns.goldRevive.w &&
|
|
ty >= btns.goldRevive.y && ty <= btns.goldRevive.y + btns.goldRevive.h) {
|
|
this._onGoldRevive();
|
|
return;
|
|
}
|
|
// Give Up button
|
|
if (tx >= btns.giveUp.x && tx <= btns.giveUp.x + btns.giveUp.w &&
|
|
ty >= btns.giveUp.y && ty <= btns.giveUp.y + btns.giveUp.h) {
|
|
this._onReviveAdGiveUp();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle pause toggle
|
|
if (this._paused) {
|
|
if (eventType === 'touchstart') {
|
|
this._paused = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Distribute touches to controls
|
|
const touches = eventType === 'touchend' ? e.changedTouches : e.touches;
|
|
for (let i = 0; i < touches.length; i++) {
|
|
const touch = touches[i];
|
|
|
|
// Try joystick first
|
|
if (this._joystick.handleTouch(eventType, touch)) continue;
|
|
|
|
// Then fire button
|
|
if (this._fireButton.handleTouch(eventType, touch)) continue;
|
|
|
|
// Pause button area (top-right corner)
|
|
if (eventType === 'touchstart') {
|
|
if (touch.clientX > SCREEN_WIDTH - 50 && touch.clientY < MAP_OFFSET_Y) {
|
|
this._paused = true;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
module.exports = GameScene;
|