first commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
// BattlePassScene - DEPRECATED (removed in monetization-lite)
|
||||
const { SCENE } = require('../base/GameGlobal');
|
||||
const BattlePassScene = {
|
||||
enter() {
|
||||
// Redirect to menu since battle pass is removed
|
||||
GameGlobal.sceneManager.switchTo(SCENE.MENU);
|
||||
},
|
||||
exit() {},
|
||||
update() {},
|
||||
render() {},
|
||||
handleTouch() {},
|
||||
};
|
||||
module.exports = BattlePassScene;
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* BuffSelectScene.js
|
||||
* Pre-game buff selection screen.
|
||||
* Allows players to purchase Shield (100g) and/or Double Fire (150g)
|
||||
* before entering a game level.
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
COLORS,
|
||||
SCENE,
|
||||
} = require('../base/GameGlobal');
|
||||
const { t } = require('../i18n/I18n');
|
||||
const BuffManager = require('../managers/BuffManager');
|
||||
|
||||
const BUFF_TYPE = BuffManager.BUFF_TYPE;
|
||||
const BUFF_COST = BuffManager.BUFF_COST;
|
||||
|
||||
// Layout constants
|
||||
const CARD_W = Math.min(SCREEN_WIDTH * 0.38, 160);
|
||||
const CARD_H = Math.min(SCREEN_HEIGHT * 0.3, 140);
|
||||
const CARD_GAP = 20;
|
||||
const CARD_Y = SCREEN_HEIGHT * 0.3;
|
||||
|
||||
const BuffSelectScene = {
|
||||
_gameParams: null, // params to pass to GameScene
|
||||
_buttons: {},
|
||||
_buffManager: null,
|
||||
|
||||
enter(params) {
|
||||
this._gameParams = params || {};
|
||||
this._buffManager = GameGlobal.buffManager;
|
||||
this._buttons = {};
|
||||
|
||||
// Clear any previous buffs
|
||||
if (this._buffManager) {
|
||||
this._buffManager.clearBuffs();
|
||||
}
|
||||
|
||||
this._calculateLayout();
|
||||
},
|
||||
|
||||
exit() {},
|
||||
|
||||
_calculateLayout() {
|
||||
const cx = SCREEN_WIDTH / 2;
|
||||
|
||||
// Two buff cards side by side
|
||||
const card1X = cx - CARD_W - CARD_GAP / 2;
|
||||
const card2X = cx + CARD_GAP / 2;
|
||||
|
||||
this._buttons = {
|
||||
shield: { x: card1X, y: CARD_Y, w: CARD_W, h: CARD_H, type: BUFF_TYPE.SHIELD },
|
||||
doubleFire: { x: card2X, y: CARD_Y, w: CARD_W, h: CARD_H, type: BUFF_TYPE.DOUBLE_FIRE },
|
||||
};
|
||||
|
||||
// Start/Skip button
|
||||
const btnW = Math.min(SCREEN_WIDTH * 0.5, 200);
|
||||
const btnH = 42;
|
||||
const btnY = CARD_Y + CARD_H + 30;
|
||||
this._buttons.start = { x: cx - btnW / 2, y: btnY, w: btnW, h: btnH };
|
||||
this._buttons.skip = { x: cx - btnW / 2, y: btnY + btnH + 12, w: btnW, h: btnH };
|
||||
},
|
||||
|
||||
update(dt) {},
|
||||
|
||||
render(ctx) {
|
||||
// Background
|
||||
ctx.fillStyle = COLORS.MENU_BG;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 24px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('buff.title') || 'Pre-Game Buffs', SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.12);
|
||||
|
||||
// Gold balance
|
||||
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 16px Arial';
|
||||
ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.2);
|
||||
|
||||
// Render buff cards
|
||||
this._renderBuffCard(ctx, this._buttons.shield,
|
||||
t('buff.shield') || '🛡️ Shield',
|
||||
t('buff.shieldDesc') || 'Start with a shield layer',
|
||||
BUFF_COST[BUFF_TYPE.SHIELD],
|
||||
BUFF_TYPE.SHIELD
|
||||
);
|
||||
|
||||
this._renderBuffCard(ctx, this._buttons.doubleFire,
|
||||
t('buff.doubleFire') || '🔥 Double Fire',
|
||||
t('buff.doubleFireDesc') || '2x bullet power for 10s',
|
||||
BUFF_COST[BUFF_TYPE.DOUBLE_FIRE],
|
||||
BUFF_TYPE.DOUBLE_FIRE
|
||||
);
|
||||
|
||||
// Start button (if any buff purchased)
|
||||
const hasBuffs = this._buffManager && this._buffManager.getActiveBuffs().length > 0;
|
||||
if (hasBuffs) {
|
||||
this._renderButton(ctx, this._buttons.start, t('buff.start') || 'Start Game', '#4CAF50');
|
||||
}
|
||||
|
||||
// Skip button
|
||||
this._renderButton(ctx, this._buttons.skip, t('buff.skip') || 'Skip →', '#666666');
|
||||
},
|
||||
|
||||
_renderBuffCard(ctx, rect, title, desc, cost, buffType) {
|
||||
if (!rect) return;
|
||||
|
||||
const purchased = this._buffManager && this._buffManager.hasBuff(buffType);
|
||||
const canAfford = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(cost);
|
||||
|
||||
// Card background
|
||||
ctx.fillStyle = purchased ? 'rgba(76, 175, 80, 0.3)' : 'rgba(255,255,255,0.05)';
|
||||
ctx.strokeStyle = purchased ? '#4CAF50' : '#444444';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
// Rounded rect
|
||||
const r = 10;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rect.x + r, rect.y);
|
||||
ctx.lineTo(rect.x + rect.w - r, rect.y);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
|
||||
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
|
||||
ctx.lineTo(rect.x + r, rect.y + rect.h);
|
||||
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
|
||||
ctx.lineTo(rect.x, rect.y + r);
|
||||
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
const cx = rect.x + rect.w / 2;
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = 'bold 16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(title, cx, rect.y + 30);
|
||||
|
||||
// Description
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '11px Arial';
|
||||
ctx.fillText(desc, cx, rect.y + 55);
|
||||
|
||||
// Cost or status
|
||||
if (purchased) {
|
||||
ctx.fillStyle = '#4CAF50';
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.fillText(t('buff.purchased') || '✓ Purchased', cx, rect.y + rect.h - 25);
|
||||
} else {
|
||||
ctx.fillStyle = canAfford ? '#FFD700' : '#FF4444';
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.fillText(`🪙 ${cost}`, cx, rect.y + rect.h - 25);
|
||||
}
|
||||
},
|
||||
|
||||
_renderButton(ctx, rect, label, color) {
|
||||
if (!rect) return;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = '#333333';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const r = 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rect.x + r, rect.y);
|
||||
ctx.lineTo(rect.x + rect.w - r, rect.y);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
|
||||
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
|
||||
ctx.lineTo(rect.x + r, rect.y + rect.h);
|
||||
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
|
||||
ctx.lineTo(rect.x, rect.y + r);
|
||||
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = 'bold 16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
},
|
||||
|
||||
_hitTest(tx, ty, rect) {
|
||||
if (!rect) return false;
|
||||
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
|
||||
},
|
||||
|
||||
_startGame() {
|
||||
const sm = GameGlobal.sceneManager;
|
||||
if (!sm._scenes.has(SCENE.GAME)) {
|
||||
const GameScene = require('./GameScene');
|
||||
sm.register(SCENE.GAME, GameScene);
|
||||
}
|
||||
sm.switchTo(SCENE.GAME, this._gameParams);
|
||||
},
|
||||
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType !== 'touchstart') return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
|
||||
// Shield card
|
||||
if (this._hitTest(tx, ty, this._buttons.shield)) {
|
||||
if (this._buffManager && !this._buffManager.hasBuff(BUFF_TYPE.SHIELD)) {
|
||||
const result = this._buffManager.purchaseBuff(BUFF_TYPE.SHIELD);
|
||||
if (!result.success) {
|
||||
console.log(`[BuffSelectScene] Shield purchase failed: ${result.error}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Double Fire card
|
||||
if (this._hitTest(tx, ty, this._buttons.doubleFire)) {
|
||||
if (this._buffManager && !this._buffManager.hasBuff(BUFF_TYPE.DOUBLE_FIRE)) {
|
||||
const result = this._buffManager.purchaseBuff(BUFF_TYPE.DOUBLE_FIRE);
|
||||
if (!result.success) {
|
||||
console.log(`[BuffSelectScene] Double Fire purchase failed: ${result.error}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Start button
|
||||
if (this._hitTest(tx, ty, this._buttons.start)) {
|
||||
this._startGame();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip button
|
||||
if (this._hitTest(tx, ty, this._buttons.skip)) {
|
||||
this._startGame();
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = BuffSelectScene;
|
||||
@@ -0,0 +1,939 @@
|
||||
/**
|
||||
* 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,
|
||||
_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._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
|
||||
|
||||
// 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)
|
||||
if (btns.goldRevive) {
|
||||
const hasGold = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(200);
|
||||
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 (200)', 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) {
|
||||
// Check if revive ad is available and not yet used this level
|
||||
if (!this._reviveAdUsed) {
|
||||
// Always show revive dialog (with ad and/or gold options)
|
||||
this._showReviveAdDialog();
|
||||
} else {
|
||||
this._triggerGameOver();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 (200 gold).
|
||||
* @private
|
||||
*/
|
||||
_onGoldRevive() {
|
||||
const cm = GameGlobal.currencyManager;
|
||||
if (cm && cm.spendGold(200)) {
|
||||
this._showingReviveDialog = false;
|
||||
this._reviveDialogButtons = null;
|
||||
this._reviveAdUsed = true;
|
||||
this._revivePlayer();
|
||||
console.log('[GameScene] Player revived via gold (200)');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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._reviveAdUsed = true;
|
||||
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;
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* MenuScene.js
|
||||
* Main menu scene - displays game title and mode selection buttons.
|
||||
* Rendered entirely with Canvas API (no DOM).
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
COLORS,
|
||||
SCENE,
|
||||
GAME_MODE,
|
||||
} = require('../base/GameGlobal');
|
||||
const { t } = require('../i18n/I18n');
|
||||
|
||||
// ============================================================
|
||||
// Button Layout
|
||||
// ============================================================
|
||||
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.55, 280);
|
||||
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
|
||||
const BTN_GAP = Math.min(8, SCREEN_HEIGHT * 0.015);
|
||||
const BTN_START_Y = SCREEN_HEIGHT * 0.38;
|
||||
const BTN_X = (SCREEN_WIDTH - BTN_WIDTH) / 2;
|
||||
|
||||
// Half-width buttons for the utility row (shop, battle pass, ranking, settings)
|
||||
const HALF_BTN_WIDTH = (BTN_WIDTH - BTN_GAP) / 2;
|
||||
|
||||
// Main game mode buttons (full width, vertical)
|
||||
const MAIN_BUTTONS = [
|
||||
{ labelKey: 'menu.classic', mode: GAME_MODE.CLASSIC, scene: SCENE.GAME },
|
||||
{ labelKey: 'menu.endless', mode: GAME_MODE.ENDLESS, scene: SCENE.GAME },
|
||||
{ labelKey: 'menu.pvp', mode: GAME_MODE.PVP, scene: SCENE.PVP_ROOM },
|
||||
{ labelKey: 'menu.team3v3', mode: GAME_MODE.TEAM_3V3, scene: SCENE.TEAM_ROOM },
|
||||
];
|
||||
|
||||
// Utility buttons: shop, daily gold, ranking, settings (2x2 grid)
|
||||
const UTIL_BUTTONS = [
|
||||
{ labelKey: 'menu.shop', mode: null, scene: SCENE.SHOP },
|
||||
{ labelKey: 'dailyGold.btn', mode: null, scene: 'DAILY_GOLD' },
|
||||
{ labelKey: 'menu.ranking', mode: null, scene: SCENE.RANKING },
|
||||
{ labelKey: 'menu.settings', mode: null, scene: SCENE.SETTINGS },
|
||||
];
|
||||
|
||||
// Pre-calculate button rects for main buttons
|
||||
const mainBtnRects = MAIN_BUTTONS.map((btn, i) => ({
|
||||
x: BTN_X,
|
||||
y: BTN_START_Y + i * (BTN_HEIGHT + BTN_GAP),
|
||||
w: BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
...btn,
|
||||
}));
|
||||
|
||||
// Pre-calculate button rects for utility buttons (2x2 grid)
|
||||
const utilStartY = BTN_START_Y + MAIN_BUTTONS.length * (BTN_HEIGHT + BTN_GAP) + BTN_GAP;
|
||||
const utilBtnRects = UTIL_BUTTONS.map((btn, i) => {
|
||||
const col = i % 2;
|
||||
const row = Math.floor(i / 2);
|
||||
return {
|
||||
x: BTN_X + col * (HALF_BTN_WIDTH + BTN_GAP),
|
||||
y: utilStartY + row * (BTN_HEIGHT + BTN_GAP),
|
||||
w: HALF_BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
...btn,
|
||||
};
|
||||
});
|
||||
|
||||
// Combined list for unified iteration
|
||||
const buttonRects = [...mainBtnRects, ...utilBtnRects];
|
||||
|
||||
// ============================================================
|
||||
// Menu Scene
|
||||
// ============================================================
|
||||
const MenuScene = {
|
||||
_pressedIndex: -1,
|
||||
_tankAnim: 0, // simple animation timer
|
||||
|
||||
enter() {
|
||||
this._pressedIndex = -1;
|
||||
this._tankAnim = 0;
|
||||
|
||||
// Auto-navigate to team room if there's a pending invite teamId
|
||||
if (GameGlobal._pendingTeamId) {
|
||||
const teamId = GameGlobal._pendingTeamId;
|
||||
GameGlobal._pendingTeamId = null;
|
||||
console.log(`[MenuScene] Found pendingTeamId: ${teamId}, will auto-navigate to TeamRoomScene`);
|
||||
// Use setTimeout to allow the scene to fully initialize first
|
||||
setTimeout(() => {
|
||||
console.log(`[MenuScene] Auto-navigating to TeamRoomScene with teamId: ${teamId}`);
|
||||
const sm = GameGlobal.sceneManager;
|
||||
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
|
||||
const TeamRoomScene = require('./TeamRoomScene');
|
||||
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
|
||||
}
|
||||
sm.switchTo(SCENE.TEAM_ROOM, { teamId });
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
|
||||
exit() {},
|
||||
|
||||
update(dt) {
|
||||
this._tankAnim += dt;
|
||||
},
|
||||
|
||||
render(ctx) {
|
||||
// Background
|
||||
ctx.fillStyle = COLORS.MENU_BG;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
// Decorative top bar
|
||||
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
|
||||
gradient.addColorStop(0, '#0f3460');
|
||||
gradient.addColorStop(0.5, '#e94560');
|
||||
gradient.addColorStop(1, '#0f3460');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
|
||||
|
||||
// Gold balance display at top
|
||||
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH - 15, 12);
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 36px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('menu.title'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.15);
|
||||
|
||||
// Subtitle
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillText(t('menu.subtitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.22);
|
||||
|
||||
// Animated tank icon (simple oscillating triangle)
|
||||
this._drawTankIcon(ctx, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.30);
|
||||
|
||||
// Main game mode buttons (full width)
|
||||
for (let i = 0; i < mainBtnRects.length; i++) {
|
||||
const btn = mainBtnRects[i];
|
||||
const isPressed = this._pressedIndex === i;
|
||||
|
||||
ctx.fillStyle = isPressed ? '#0f3460' : COLORS.MENU_BTN;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = 'bold 18px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t(btn.labelKey), btn.x + btn.w / 2, btn.y + btn.h / 2);
|
||||
}
|
||||
|
||||
// Utility buttons (2x2 grid, smaller font)
|
||||
for (let i = 0; i < utilBtnRects.length; i++) {
|
||||
const btn = utilBtnRects[i];
|
||||
const globalIdx = mainBtnRects.length + i;
|
||||
const isPressed = this._pressedIndex === globalIdx;
|
||||
|
||||
// Special rendering for daily gold button
|
||||
const isDailyGold = btn.scene === 'DAILY_GOLD';
|
||||
let label = t(btn.labelKey);
|
||||
let btnColor = COLORS.MENU_BTN;
|
||||
|
||||
if (isDailyGold) {
|
||||
const remaining = GameGlobal.adManager ? GameGlobal.adManager.getDailyGoldRemaining() : 0;
|
||||
if (remaining > 0) {
|
||||
label = `${t('dailyGold.btn')} ${remaining}/3`;
|
||||
btnColor = '#2E7D32'; // green tint
|
||||
} else {
|
||||
label = t('dailyGold.exhausted') || 'Come back tomorrow';
|
||||
btnColor = '#555555';
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = isPressed ? '#0f3460' : btnColor;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, 6);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2);
|
||||
}
|
||||
|
||||
// Footer
|
||||
ctx.fillStyle = '#555555';
|
||||
ctx.font = '11px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20);
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw a simple animated tank icon.
|
||||
*/
|
||||
_drawTankIcon(ctx, cx, cy) {
|
||||
const bounce = Math.sin(this._tankAnim * 3) * 3;
|
||||
const size = 20;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy + bounce);
|
||||
|
||||
// Tank body
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.fillRect(-size, -size / 2, size * 2, size);
|
||||
|
||||
// Tank turret
|
||||
ctx.fillRect(-3, -size / 2 - 14, 6, 14);
|
||||
|
||||
// Tank tracks
|
||||
ctx.fillStyle = '#B8860B';
|
||||
ctx.fillRect(-size - 4, -size / 2, 4, size);
|
||||
ctx.fillRect(size, -size / 2, 4, size);
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw a rounded rectangle path.
|
||||
*/
|
||||
_roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.arcTo(x, y, x + r, y, r);
|
||||
ctx.closePath();
|
||||
},
|
||||
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType === 'touchstart') {
|
||||
const touch = e.touches[0];
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
|
||||
for (let i = 0; i < buttonRects.length; i++) {
|
||||
const btn = buttonRects[i];
|
||||
if (tx >= btn.x && tx <= btn.x + btn.w && ty >= btn.y && ty <= btn.y + btn.h) {
|
||||
this._pressedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (eventType === 'touchend') {
|
||||
if (this._pressedIndex >= 0) {
|
||||
const btn = buttonRects[this._pressedIndex];
|
||||
this._pressedIndex = -1;
|
||||
|
||||
// Navigate to the target scene
|
||||
const sm = GameGlobal.sceneManager;
|
||||
if (btn.scene === SCENE.GAME) {
|
||||
// Route through BuffSelectScene for PvE modes
|
||||
if (!sm._scenes.has(SCENE.BUFF_SELECT)) {
|
||||
const BuffSelectScene = require('./BuffSelectScene');
|
||||
sm.register(SCENE.BUFF_SELECT, BuffSelectScene);
|
||||
}
|
||||
sm.switchTo(SCENE.BUFF_SELECT, { mode: btn.mode });
|
||||
} else if (btn.scene === 'DAILY_GOLD') {
|
||||
// Handle daily gold ad
|
||||
const adm = GameGlobal.adManager;
|
||||
if (adm && adm.getDailyGoldRemaining() > 0) {
|
||||
adm.showDailyGoldAd((completed) => {
|
||||
if (completed) {
|
||||
try {
|
||||
wx.showToast({ title: t('dailyGold.reward') || '+100 Gold!', icon: 'none', duration: 1500 });
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (btn.scene === SCENE.SHOP) {
|
||||
if (!sm._scenes.has(SCENE.SHOP)) {
|
||||
const ShopScene = require('./ShopScene');
|
||||
sm.register(SCENE.SHOP, ShopScene);
|
||||
}
|
||||
sm.switchTo(SCENE.SHOP);
|
||||
} else if (btn.scene === SCENE.SETTINGS) {
|
||||
if (!sm._scenes.has(SCENE.SETTINGS)) {
|
||||
const SettingsScene = require('./SettingsScene');
|
||||
sm.register(SCENE.SETTINGS, SettingsScene);
|
||||
}
|
||||
sm.switchTo(SCENE.SETTINGS);
|
||||
} else if (btn.scene === SCENE.RANKING) {
|
||||
if (!sm._scenes.has(SCENE.RANKING)) {
|
||||
const RankingScene = require('./RankingScene');
|
||||
sm.register(SCENE.RANKING, RankingScene);
|
||||
}
|
||||
sm.switchTo(SCENE.RANKING);
|
||||
} else if (btn.scene === SCENE.PVP_ROOM) {
|
||||
if (!sm._scenes.has(SCENE.PVP_ROOM)) {
|
||||
const RoomScene = require('./RoomScene');
|
||||
sm.register(SCENE.PVP_ROOM, RoomScene);
|
||||
}
|
||||
sm.switchTo(SCENE.PVP_ROOM);
|
||||
} else if (btn.scene === SCENE.TEAM_ROOM) {
|
||||
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
|
||||
const TeamRoomScene = require('./TeamRoomScene');
|
||||
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
|
||||
}
|
||||
sm.switchTo(SCENE.TEAM_ROOM);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = MenuScene;
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* RankingScene.js
|
||||
* Ranking/leaderboard scene.
|
||||
* In production, this would use WeChat Open Data Domain (SharedCanvas).
|
||||
* For now, displays local high scores with a placeholder for friend rankings.
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
COLORS,
|
||||
SCENE,
|
||||
GAME_MODE,
|
||||
} = require('../base/GameGlobal');
|
||||
const { t } = require('../i18n/I18n');
|
||||
|
||||
const StorageManager = require('../managers/StorageManager');
|
||||
|
||||
const RankingScene = {
|
||||
_storage: null,
|
||||
_scores: [],
|
||||
_buttons: {},
|
||||
|
||||
enter() {
|
||||
this._storage = new StorageManager();
|
||||
this._buttons = {};
|
||||
|
||||
// Load local scores
|
||||
this._scores = [
|
||||
{
|
||||
label: t('ranking.classicHigh'),
|
||||
score: this._storage.getHighScore(GAME_MODE.CLASSIC),
|
||||
},
|
||||
{
|
||||
label: t('ranking.endlessHigh'),
|
||||
score: this._storage.getHighScore(GAME_MODE.ENDLESS),
|
||||
},
|
||||
{
|
||||
label: t('ranking.highestLevel'),
|
||||
score: this._storage.getHighestLevel(),
|
||||
suffix: t('ranking.levelSuffix'),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
exit() {},
|
||||
|
||||
update(dt) {},
|
||||
|
||||
render(ctx) {
|
||||
// Background
|
||||
ctx.fillStyle = COLORS.MENU_BG;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
const cx = SCREEN_WIDTH / 2;
|
||||
let y = 60;
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 28px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('ranking.title'), cx, y);
|
||||
|
||||
y += 60;
|
||||
|
||||
// Local scores section
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillText(t('ranking.personalRecord'), cx, y);
|
||||
|
||||
y += 40;
|
||||
|
||||
for (const item of this._scores) {
|
||||
// Card background
|
||||
const cardW = SCREEN_WIDTH * 0.75;
|
||||
const cardH = 55;
|
||||
const cardX = cx - cardW / 2;
|
||||
|
||||
ctx.fillStyle = '#1e1e3a';
|
||||
ctx.fillRect(cardX, y - cardH / 2, cardW, cardH);
|
||||
ctx.strokeStyle = '#333366';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(cardX, y - cardH / 2, cardW, cardH);
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = COLORS.HUD_TEXT;
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(item.label, cardX + 15, y - 5);
|
||||
|
||||
// Score
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 18px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
const suffix = item.suffix || t('ranking.scoreSuffix');
|
||||
ctx.fillText(`${item.score} ${suffix}`, cardX + cardW - 15, y + 2);
|
||||
|
||||
y += 70;
|
||||
}
|
||||
|
||||
y += 20;
|
||||
|
||||
// Friend ranking placeholder
|
||||
ctx.fillStyle = '#666666';
|
||||
ctx.font = '13px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('ranking.friendHint'), cx, y);
|
||||
ctx.fillText('(SharedCanvas)', cx, y + 20);
|
||||
|
||||
// Back button
|
||||
y = SCREEN_HEIGHT - 80;
|
||||
const btnW = SCREEN_WIDTH * 0.4;
|
||||
const btnH = 42;
|
||||
const btnX = cx - btnW / 2;
|
||||
|
||||
this._buttons['back'] = { x: btnX, y: y - btnH / 2, w: btnW, h: btnH };
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_BTN;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.fillRect(btnX, y - btnH / 2, btnW, btnH);
|
||||
ctx.strokeRect(btnX, y - btnH / 2, btnW, btnH);
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = 'bold 16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('common.back'), cx, y);
|
||||
},
|
||||
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType !== 'touchstart') return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
|
||||
const back = this._buttons['back'];
|
||||
if (back && tx >= back.x && tx <= back.x + back.w && ty >= back.y && ty <= back.y + back.h) {
|
||||
GameGlobal.sceneManager.switchTo(SCENE.MENU);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = RankingScene;
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* ResultScene.js
|
||||
* Post-game result/settlement screen showing stats, score, and navigation options.
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
COLORS,
|
||||
SCENE,
|
||||
GAME_MODE,
|
||||
TANK_TYPE,
|
||||
} = require('../base/GameGlobal');
|
||||
const { t } = require('../i18n/I18n');
|
||||
|
||||
const ResultScene = {
|
||||
_level: 1,
|
||||
_mode: GAME_MODE.CLASSIC,
|
||||
_victory: false,
|
||||
_stats: null,
|
||||
_animTimer: 0,
|
||||
_showButtons: false,
|
||||
|
||||
_isNewHighScore: false,
|
||||
_adWatched: false,
|
||||
|
||||
enter(params) {
|
||||
this._level = params.level || 1;
|
||||
this._mode = params.mode || GAME_MODE.CLASSIC;
|
||||
this._victory = params.victory || false;
|
||||
this._stats = params.stats || {
|
||||
kills: { normal: 0, fast: 0, armor: 0, boss: 0 },
|
||||
totalKills: 0,
|
||||
score: 0,
|
||||
timeElapsed: 0,
|
||||
baseAlive: true,
|
||||
};
|
||||
this._animTimer = 0;
|
||||
this._showButtons = false;
|
||||
this._isNewHighScore = false;
|
||||
this._adWatched = false;
|
||||
this._buttons = {};
|
||||
|
||||
// Save score and progress
|
||||
this._saveResults();
|
||||
|
||||
// Interstitial ad is shown when player exits (next/retry/menu), not on enter
|
||||
|
||||
console.log(`[ResultScene] ${this._victory ? 'Victory' : 'Defeat'} - Score: ${this._stats.score}`);
|
||||
},
|
||||
|
||||
_saveResults() {
|
||||
const sm = GameGlobal.storageManager;
|
||||
if (!sm) return;
|
||||
|
||||
// Update high score
|
||||
this._isNewHighScore = sm.updateHighScore(this._mode, this._stats.score);
|
||||
|
||||
// Update highest level
|
||||
if (this._victory) {
|
||||
sm.updateHighestLevel(this._level);
|
||||
}
|
||||
|
||||
// Update open data for friend ranking
|
||||
if (GameGlobal.shareManager) {
|
||||
GameGlobal.shareManager.updateOpenData(this._stats.score, this._level);
|
||||
}
|
||||
|
||||
// Calculate and award gold coins
|
||||
this._goldReward = this._calculateGoldReward();
|
||||
if (this._goldReward > 0 && GameGlobal.currencyManager) {
|
||||
GameGlobal.currencyManager.addGold(this._goldReward);
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate gold reward based on game performance.
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
_calculateGoldReward() {
|
||||
const stats = this._stats;
|
||||
let gold = 50; // Base reward per requirements
|
||||
|
||||
// Bonus per kill type
|
||||
gold += (stats.kills.normal || 0) * 5;
|
||||
gold += (stats.kills.fast || 0) * 10;
|
||||
gold += (stats.kills.armor || 0) * 15;
|
||||
gold += (stats.kills.boss || 0) * 25;
|
||||
|
||||
// Victory bonus
|
||||
if (this._victory) {
|
||||
gold += 50;
|
||||
}
|
||||
|
||||
// Time bonus (faster = more gold, max 30 gold for under 60s)
|
||||
if (this._victory && stats.timeElapsed < 300) {
|
||||
gold += Math.max(0, Math.floor((300 - stats.timeElapsed) / 10));
|
||||
}
|
||||
|
||||
// Base alive bonus
|
||||
if (stats.baseAlive) {
|
||||
gold += 20;
|
||||
}
|
||||
|
||||
return gold;
|
||||
},
|
||||
|
||||
exit() {},
|
||||
|
||||
update(dt) {
|
||||
this._animTimer += dt;
|
||||
if (this._animTimer > 1.5 && !this._showButtons) {
|
||||
this._showButtons = true;
|
||||
}
|
||||
},
|
||||
|
||||
render(ctx) {
|
||||
// Background
|
||||
ctx.fillStyle = '#0a0a1a';
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
const cx = SCREEN_WIDTH / 2;
|
||||
let y = 35;
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = this._victory ? '#00FF00' : '#FF4444';
|
||||
ctx.font = 'bold 24px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
this._victory ? t('result.victory') : t('result.defeat'),
|
||||
cx,
|
||||
y
|
||||
);
|
||||
|
||||
y += 30;
|
||||
|
||||
// Level info
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillText(t('result.level', { level: this._level }), cx, y);
|
||||
|
||||
y += 35;
|
||||
|
||||
// Kill statistics - horizontal table layout
|
||||
ctx.fillStyle = COLORS.HUD_TEXT;
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.fillText(t('result.killStats'), cx, y);
|
||||
|
||||
y += 20;
|
||||
|
||||
const killData = [
|
||||
{ label: t('result.tankNormal'), count: this._stats.kills.normal, score: 100, color: '#AAAAAA' },
|
||||
{ label: t('result.tankFast'), count: this._stats.kills.fast, score: 200, color: '#FF6347' },
|
||||
{ label: t('result.tankArmor'), count: this._stats.kills.armor, score: 300, color: '#228B22' },
|
||||
{ label: t('result.tankBoss'), count: this._stats.kills.boss, score: 500, color: '#8B0000' },
|
||||
{ label: t('result.totalLabel'), count: this._stats.totalKills, score: null, total: this._stats.score, color: '#FFD700' },
|
||||
];
|
||||
|
||||
// Table layout: first column for row labels, then 5 data columns
|
||||
const colCount = killData.length;
|
||||
const rowLabelWidth = 30; // Width for row label column
|
||||
const tableWidth = SCREEN_WIDTH * 0.55;
|
||||
const tableLeft = (SCREEN_WIDTH - tableWidth) / 2;
|
||||
const dataColWidth = (tableWidth - rowLabelWidth) / colCount;
|
||||
const dataLeft = tableLeft + rowLabelWidth;
|
||||
|
||||
// Row 1: Column headers (blank first col + tank type names + total)
|
||||
ctx.font = 'bold 11px Arial';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
const showDelay = i * 0.3;
|
||||
if (this._animTimer < showDelay) continue;
|
||||
const colCx = dataLeft + dataColWidth * i + dataColWidth / 2;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = killData[i].color;
|
||||
ctx.fillText(killData[i].label, colCx, y);
|
||||
}
|
||||
|
||||
y += 16;
|
||||
|
||||
// Row 2: Kill counts (first col = "击杀")
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.fillText(t('result.rowKills'), tableLeft, y);
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
const showDelay = i * 0.3;
|
||||
if (this._animTimer < showDelay) continue;
|
||||
const colCx = dataLeft + dataColWidth * i + dataColWidth / 2;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = COLORS.HUD_TEXT;
|
||||
ctx.fillText(`×${killData[i].count}`, colCx, y);
|
||||
}
|
||||
|
||||
y += 16;
|
||||
|
||||
// Row 3: Scores (first col = "得分")
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.fillText(t('result.rowScore'), tableLeft, y);
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
const showDelay = i * 0.3;
|
||||
if (this._animTimer < showDelay) continue;
|
||||
const colCx = dataLeft + dataColWidth * i + dataColWidth / 2;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = '#FFD700';
|
||||
const scoreVal = killData[i].total != null ? killData[i].total : killData[i].count * killData[i].score;
|
||||
ctx.fillText(`${scoreVal}`, colCx, y);
|
||||
}
|
||||
|
||||
y += 10;
|
||||
|
||||
// Divider
|
||||
ctx.strokeStyle = '#333333';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - 120, y);
|
||||
ctx.lineTo(cx + 120, y);
|
||||
ctx.stroke();
|
||||
|
||||
y += 18;
|
||||
|
||||
// Time
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '12px Arial';
|
||||
const minutes = Math.floor(this._stats.timeElapsed / 60);
|
||||
const seconds = Math.floor(this._stats.timeElapsed % 60);
|
||||
ctx.fillText(
|
||||
t('result.time', { minutes, seconds: seconds.toString().padStart(2, '0') }),
|
||||
cx,
|
||||
y
|
||||
);
|
||||
|
||||
y += 18;
|
||||
|
||||
// Base status
|
||||
ctx.fillText(
|
||||
this._stats.baseAlive ? t('result.baseAlive') : t('result.baseDestroyed'),
|
||||
cx,
|
||||
y
|
||||
);
|
||||
|
||||
// New high score indicator
|
||||
if (this._isNewHighScore) {
|
||||
y += 20;
|
||||
ctx.fillStyle = '#FF69B4';
|
||||
ctx.font = 'bold 13px Arial';
|
||||
ctx.fillText(t('result.newRecord'), cx, y);
|
||||
}
|
||||
|
||||
// Gold reward display
|
||||
if (this._goldReward > 0) {
|
||||
y += 22;
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 14px Arial';
|
||||
const goldLabel = this._adWatched
|
||||
? `🪙 +${this._goldReward} (${t('result.doubled') || '2x!'})`
|
||||
: `🪙 +${this._goldReward}`;
|
||||
ctx.fillText(goldLabel, cx, y);
|
||||
}
|
||||
|
||||
// Buttons (shown after animation)
|
||||
if (this._showButtons) {
|
||||
// Calculate how many buttons will be shown
|
||||
let btnCount = 2; // retry + menu always present
|
||||
if (this._victory) btnCount += 2; // share + next
|
||||
if (!this._adWatched) btnCount += 1; // ad_double
|
||||
const btnSpacing = 38;
|
||||
const totalBtnHeight = btnCount * btnSpacing;
|
||||
// Start buttons so they end 15px above screen bottom
|
||||
y = SCREEN_HEIGHT - totalBtnHeight - 15;
|
||||
|
||||
// Share challenge button
|
||||
if (this._victory) {
|
||||
this._drawButton(ctx, cx, y, t('result.share'), 'share');
|
||||
y += btnSpacing;
|
||||
}
|
||||
|
||||
// Double score ad button (if not watched)
|
||||
if (!this._adWatched) {
|
||||
this._drawButton(ctx, cx, y, t('result.adDouble'), 'ad_double');
|
||||
y += btnSpacing;
|
||||
}
|
||||
|
||||
if (this._victory) {
|
||||
this._drawButton(ctx, cx, y, t('result.nextLevel'), 'next');
|
||||
y += btnSpacing;
|
||||
}
|
||||
|
||||
// Retry button
|
||||
this._drawButton(ctx, cx, y, t('result.retry'), 'retry');
|
||||
y += btnSpacing;
|
||||
|
||||
// Menu button
|
||||
this._drawButton(ctx, cx, y, t('result.backMenu'), 'menu');
|
||||
}
|
||||
},
|
||||
|
||||
_drawButton(ctx, cx, y, label, id) {
|
||||
const w = SCREEN_WIDTH * 0.55;
|
||||
const h = 36;
|
||||
const x = cx - w / 2;
|
||||
|
||||
// Store button rect for touch detection
|
||||
if (!this._buttons) this._buttons = {};
|
||||
this._buttons[id] = { x, y: y - h / 2, w, h };
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_BTN;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
// Rounded rect
|
||||
ctx.beginPath();
|
||||
const r = 6;
|
||||
ctx.moveTo(x + r, y - h / 2);
|
||||
ctx.lineTo(x + w - r, y - h / 2);
|
||||
ctx.arcTo(x + w, y - h / 2, x + w, y - h / 2 + r, r);
|
||||
ctx.lineTo(x + w, y + h / 2 - r);
|
||||
ctx.arcTo(x + w, y + h / 2, x + w - r, y + h / 2, r);
|
||||
ctx.lineTo(x + r, y + h / 2);
|
||||
ctx.arcTo(x, y + h / 2, x, y + h / 2 - r, r);
|
||||
ctx.lineTo(x, y - h / 2 + r);
|
||||
ctx.arcTo(x, y - h / 2, x + r, y - h / 2, r);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, cx, y);
|
||||
},
|
||||
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType !== 'touchstart' || !this._showButtons) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
|
||||
if (!this._buttons) return;
|
||||
|
||||
for (const [id, rect] of Object.entries(this._buttons)) {
|
||||
if (tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h) {
|
||||
this._handleButtonPress(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_handleButtonPress(id) {
|
||||
const sm = GameGlobal.sceneManager;
|
||||
|
||||
switch (id) {
|
||||
case 'next':
|
||||
sm.switchTo(SCENE.GAME, {
|
||||
mode: this._mode,
|
||||
level: this._level + 1,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'retry':
|
||||
sm.switchTo(SCENE.GAME, {
|
||||
mode: this._mode,
|
||||
level: this._level,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'menu':
|
||||
// Show interstitial ad when leaving result screen
|
||||
if (GameGlobal.adManager) {
|
||||
GameGlobal.adManager.showInterstitial();
|
||||
}
|
||||
sm.switchTo(SCENE.MENU);
|
||||
break;
|
||||
|
||||
case 'share':
|
||||
if (GameGlobal.shareManager) {
|
||||
GameGlobal.shareManager.shareChallenge(this._level, this._stats.score);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ad_double': {
|
||||
const AdManager = require('../managers/AdManager');
|
||||
if (GameGlobal.adManager &&
|
||||
GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.DOUBLE_REWARD)) {
|
||||
GameGlobal.adManager.showRewardedVideoForScene(
|
||||
AdManager.AD_SCENE.DOUBLE_REWARD,
|
||||
(completed) => {
|
||||
if (completed) {
|
||||
this._stats.score *= 2;
|
||||
this._adWatched = true;
|
||||
// Re-save with doubled score
|
||||
if (GameGlobal.storageManager) {
|
||||
GameGlobal.storageManager.updateHighScore(this._mode, this._stats.score);
|
||||
}
|
||||
// Award bonus gold (double the original reward)
|
||||
if (this._goldReward && GameGlobal.currencyManager) {
|
||||
GameGlobal.currencyManager.addGold(this._goldReward);
|
||||
this._goldReward *= 2; // Update display
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.warn('[ResultScene] Double reward ad not available');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = ResultScene;
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* RoomScene.js
|
||||
* Room creation/joining UI for PVP online multiplayer mode.
|
||||
* Allows players to create a room or join an existing one by room code.
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
COLORS,
|
||||
SCENE,
|
||||
NET_MSG,
|
||||
SERVER_URL,
|
||||
} = require('../base/GameGlobal');
|
||||
const { t } = require('../i18n/I18n');
|
||||
|
||||
// ============================================================
|
||||
// Layout Constants
|
||||
// ============================================================
|
||||
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.5, 240);
|
||||
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.08);
|
||||
const BTN_GAP = 14;
|
||||
const CENTER_X = SCREEN_WIDTH / 2;
|
||||
|
||||
// ============================================================
|
||||
// Room Scene States
|
||||
// ============================================================
|
||||
const ROOM_STATE = {
|
||||
IDLE: 'idle', // Initial state: show create/join buttons
|
||||
CREATING: 'creating', // Connecting and creating room
|
||||
WAITING: 'waiting', // Room created, waiting for opponent
|
||||
JOINING: 'joining', // Joining a room
|
||||
INPUT_CODE: 'input', // Entering room code
|
||||
COUNTDOWN: 'countdown', // Both players ready, counting down
|
||||
ERROR: 'error', // Error state
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Room Scene
|
||||
// ============================================================
|
||||
const RoomScene = {
|
||||
_state: ROOM_STATE.IDLE,
|
||||
_roomCode: '',
|
||||
_inputCode: '',
|
||||
_errorMsg: '',
|
||||
_countdown: 3,
|
||||
_countdownTimer: 0,
|
||||
_animTimer: 0,
|
||||
_networkManager: null,
|
||||
_unsubscribers: [],
|
||||
|
||||
// Server URL (from global config)
|
||||
_serverUrl: SERVER_URL,
|
||||
|
||||
// Button rects (calculated in enter)
|
||||
_createBtnRect: null,
|
||||
_joinBtnRect: null,
|
||||
_backBtnRect: null,
|
||||
_confirmBtnRect: null,
|
||||
_numpadRects: [],
|
||||
_deleteBtnRect: null,
|
||||
|
||||
enter() {
|
||||
this._state = ROOM_STATE.IDLE;
|
||||
this._roomCode = '';
|
||||
this._inputCode = '';
|
||||
this._errorMsg = '';
|
||||
this._countdown = 3;
|
||||
this._countdownTimer = 0;
|
||||
this._animTimer = 0;
|
||||
this._pendingStartData = null;
|
||||
this._networkManager = GameGlobal.networkManager;
|
||||
|
||||
// Calculate button positions
|
||||
const btnY = SCREEN_HEIGHT * 0.4;
|
||||
this._createBtnRect = {
|
||||
x: CENTER_X - BTN_WIDTH / 2,
|
||||
y: btnY,
|
||||
w: BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
this._joinBtnRect = {
|
||||
x: CENTER_X - BTN_WIDTH / 2,
|
||||
y: btnY + BTN_HEIGHT + BTN_GAP,
|
||||
w: BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
this._backBtnRect = {
|
||||
x: 10,
|
||||
y: 10,
|
||||
w: 60,
|
||||
h: 30,
|
||||
};
|
||||
|
||||
// Numpad for room code input (3x4 grid: 1-9, 0, delete, confirm)
|
||||
this._buildNumpad();
|
||||
|
||||
// Confirm button for code input
|
||||
this._confirmBtnRect = {
|
||||
x: CENTER_X - BTN_WIDTH / 2,
|
||||
y: SCREEN_HEIGHT * 0.75,
|
||||
w: BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
|
||||
// Setup network event listeners
|
||||
this._setupNetworkEvents();
|
||||
},
|
||||
|
||||
exit() {
|
||||
this._cleanupNetworkEvents();
|
||||
},
|
||||
|
||||
_buildNumpad() {
|
||||
const padWidth = Math.min(SCREEN_WIDTH * 0.6, 200);
|
||||
const padHeight = Math.min(SCREEN_HEIGHT * 0.35, 180);
|
||||
const startX = CENTER_X - padWidth / 2;
|
||||
const startY = SCREEN_HEIGHT * 0.42;
|
||||
const cellW = padWidth / 3;
|
||||
const cellH = padHeight / 4;
|
||||
|
||||
this._numpadRects = [];
|
||||
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, null, 0, 'del'];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const col = i % 3;
|
||||
const row = Math.floor(i / 3);
|
||||
if (nums[i] !== null) {
|
||||
this._numpadRects.push({
|
||||
x: startX + col * cellW,
|
||||
y: startY + row * cellH,
|
||||
w: cellW - 4,
|
||||
h: cellH - 4,
|
||||
value: nums[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_setupNetworkEvents() {
|
||||
this._cleanupNetworkEvents();
|
||||
const nm = this._networkManager;
|
||||
if (!nm) return;
|
||||
|
||||
const unsubs = [];
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.ROOM_CREATED, (data) => {
|
||||
this._roomCode = data.roomId || data.roomCode || '';
|
||||
this._state = ROOM_STATE.WAITING;
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.ROOM_JOINED, (data) => {
|
||||
this._roomCode = data.roomId || data.roomCode || '';
|
||||
this._state = ROOM_STATE.WAITING;
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.OPPONENT_JOINED, () => {
|
||||
this._state = ROOM_STATE.COUNTDOWN;
|
||||
this._countdown = 3;
|
||||
this._countdownTimer = 0;
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.GAME_START, (data) => {
|
||||
// Server authoritative game start — always use server data (contains mapId)
|
||||
this._pendingStartData = data;
|
||||
if (this._state !== ROOM_STATE.COUNTDOWN) {
|
||||
// Guest path: not in countdown state, start game immediately
|
||||
this._startGame(data);
|
||||
} else if (this._countdown <= 0) {
|
||||
// Host path: countdown already finished, start immediately
|
||||
this._startGame(data);
|
||||
}
|
||||
// Host path: countdown still running, will pick up pendingStartData when done
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
|
||||
this._errorMsg = data.message || 'Unknown error';
|
||||
this._state = ROOM_STATE.ERROR;
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on('error', () => {
|
||||
this._errorMsg = t('common.connectFailed');
|
||||
this._state = ROOM_STATE.ERROR;
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on('disconnected', () => {
|
||||
if (this._state !== ROOM_STATE.IDLE) {
|
||||
this._errorMsg = t('common.disconnected');
|
||||
this._state = ROOM_STATE.ERROR;
|
||||
}
|
||||
}));
|
||||
|
||||
this._unsubscribers = unsubs;
|
||||
},
|
||||
|
||||
_cleanupNetworkEvents() {
|
||||
for (const unsub of this._unsubscribers) {
|
||||
if (typeof unsub === 'function') unsub();
|
||||
}
|
||||
this._unsubscribers = [];
|
||||
},
|
||||
|
||||
update(dt) {
|
||||
this._animTimer += dt;
|
||||
|
||||
if (this._state === ROOM_STATE.COUNTDOWN) {
|
||||
this._countdownTimer += dt;
|
||||
if (this._countdownTimer >= 1) {
|
||||
this._countdownTimer -= 1;
|
||||
this._countdown--;
|
||||
if (this._countdown <= 0) {
|
||||
// Countdown finished — only start if we already received server GAME_START
|
||||
if (this._pendingStartData) {
|
||||
this._startGame(this._pendingStartData);
|
||||
}
|
||||
// Otherwise wait for server GAME_START message
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_startGame(data) {
|
||||
const sm = GameGlobal.sceneManager;
|
||||
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
|
||||
const TeamGameScene = require('./TeamGameScene');
|
||||
sm.register(SCENE.TEAM_GAME, TeamGameScene);
|
||||
}
|
||||
|
||||
const myPlayerId = this._networkManager ? this._networkManager.playerId : 'local';
|
||||
const playerSlot = this._networkManager ? this._networkManager.playerSlot : 1;
|
||||
|
||||
// Build teamA/teamB from GAME_START data (sent by server for 1v1 via TeamRoom)
|
||||
let teamA = data.teamA || [];
|
||||
let teamB = data.teamB || [];
|
||||
|
||||
// Fallback: if server didn't send teamA/teamB (legacy), construct from playerSlot
|
||||
if (teamA.length === 0 && teamB.length === 0) {
|
||||
if (playerSlot === 1) {
|
||||
teamA = [{ playerId: myPlayerId, isBot: false }];
|
||||
teamB = [{ playerId: 'opponent', isBot: false }];
|
||||
} else {
|
||||
teamA = [{ playerId: 'opponent', isBot: false }];
|
||||
teamB = [{ playerId: myPlayerId, isBot: false }];
|
||||
}
|
||||
}
|
||||
|
||||
sm.switchTo(SCENE.TEAM_GAME, {
|
||||
teamId: this._roomCode,
|
||||
roomId: data.roomId || this._roomCode,
|
||||
mapId: data.mapId || null,
|
||||
teamA,
|
||||
teamB,
|
||||
teamABaseHp: data.teamABaseHp,
|
||||
teamBBaseHp: data.teamBBaseHp,
|
||||
myPlayerId,
|
||||
battleMode: data.battleMode || '1v1',
|
||||
});
|
||||
},
|
||||
|
||||
render(ctx) {
|
||||
// Background
|
||||
ctx.fillStyle = COLORS.MENU_BG;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
// Top bar
|
||||
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
|
||||
gradient.addColorStop(0, '#0f3460');
|
||||
gradient.addColorStop(0.5, '#e94560');
|
||||
gradient.addColorStop(1, '#0f3460');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
|
||||
|
||||
// Back button
|
||||
this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12);
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 24px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('room.title'), CENTER_X, SCREEN_HEIGHT * 0.12);
|
||||
|
||||
// Render based on state
|
||||
switch (this._state) {
|
||||
case ROOM_STATE.IDLE:
|
||||
this._renderIdle(ctx);
|
||||
break;
|
||||
case ROOM_STATE.CREATING:
|
||||
case ROOM_STATE.JOINING:
|
||||
this._renderConnecting(ctx);
|
||||
break;
|
||||
case ROOM_STATE.WAITING:
|
||||
this._renderWaiting(ctx);
|
||||
break;
|
||||
case ROOM_STATE.INPUT_CODE:
|
||||
this._renderInputCode(ctx);
|
||||
break;
|
||||
case ROOM_STATE.COUNTDOWN:
|
||||
this._renderCountdown(ctx);
|
||||
break;
|
||||
case ROOM_STATE.ERROR:
|
||||
this._renderError(ctx);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_renderIdle(ctx) {
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('room.idleHint'), CENTER_X, SCREEN_HEIGHT * 0.28);
|
||||
|
||||
this._drawButton(ctx, this._createBtnRect, t('room.create'));
|
||||
this._drawButton(ctx, this._joinBtnRect, t('room.join'));
|
||||
},
|
||||
|
||||
_renderConnecting(ctx) {
|
||||
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('room.connecting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.5);
|
||||
},
|
||||
|
||||
_renderWaiting(ctx) {
|
||||
// Room code display
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('room.roomCode'), CENTER_X, SCREEN_HEIGHT * 0.32);
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 36px Arial';
|
||||
ctx.fillText(this._roomCode, CENTER_X, SCREEN_HEIGHT * 0.42);
|
||||
|
||||
// Waiting animation
|
||||
const dots = '.'.repeat(Math.floor(this._animTimer * 2) % 4);
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '16px Arial';
|
||||
ctx.fillText(t('room.waiting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
|
||||
|
||||
// Hint
|
||||
ctx.fillStyle = '#666666';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.fillText(t('room.shareHint'), CENTER_X, SCREEN_HEIGHT * 0.65);
|
||||
},
|
||||
|
||||
_renderInputCode(ctx) {
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('room.inputCode'), CENTER_X, SCREEN_HEIGHT * 0.25);
|
||||
|
||||
// Code display box
|
||||
const boxW = Math.min(SCREEN_WIDTH * 0.5, 180);
|
||||
const boxH = 40;
|
||||
const boxX = CENTER_X - boxW / 2;
|
||||
const boxY = SCREEN_HEIGHT * 0.30;
|
||||
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
ctx.strokeStyle = COLORS.MENU_TITLE;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.fillRect(boxX, boxY, boxW, boxH);
|
||||
ctx.strokeRect(boxX, boxY, boxW, boxH);
|
||||
|
||||
// Input text
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 24px Arial';
|
||||
const displayCode = this._inputCode + (Math.floor(this._animTimer * 2) % 2 === 0 ? '|' : '');
|
||||
ctx.fillText(displayCode, CENTER_X, boxY + boxH / 2);
|
||||
|
||||
// Numpad
|
||||
for (const btn of this._numpadRects) {
|
||||
const label = btn.value === 'del' ? '⌫' : String(btn.value);
|
||||
this._drawButton(ctx, btn, label, false, 16);
|
||||
}
|
||||
|
||||
// Confirm button
|
||||
if (this._inputCode.length >= 4) {
|
||||
this._drawButton(ctx, this._confirmBtnRect, t('common.joinBtn'), false, 16);
|
||||
}
|
||||
},
|
||||
|
||||
_renderCountdown(ctx) {
|
||||
ctx.fillStyle = '#00FF00';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('room.opponentFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 64px Arial';
|
||||
ctx.fillText(String(this._countdown), CENTER_X, SCREEN_HEIGHT * 0.52);
|
||||
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillText(t('room.starting'), CENTER_X, SCREEN_HEIGHT * 0.65);
|
||||
},
|
||||
|
||||
_renderError(ctx) {
|
||||
ctx.fillStyle = '#FF4444';
|
||||
ctx.font = '16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(this._errorMsg, CENTER_X, SCREEN_HEIGHT * 0.45);
|
||||
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillText(t('room.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
|
||||
},
|
||||
|
||||
_drawButton(ctx, rect, label, pressed, fontSize) {
|
||||
if (!rect) return;
|
||||
const fs = fontSize || 16;
|
||||
|
||||
ctx.fillStyle = pressed ? '#0f3460' : COLORS.MENU_BTN;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
// Rounded rect
|
||||
const r = 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rect.x + r, rect.y);
|
||||
ctx.lineTo(rect.x + rect.w - r, rect.y);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
|
||||
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
|
||||
ctx.lineTo(rect.x + r, rect.y + rect.h);
|
||||
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
|
||||
ctx.lineTo(rect.x, rect.y + r);
|
||||
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = pressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = `bold ${fs}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
},
|
||||
|
||||
_hitTest(tx, ty, rect) {
|
||||
if (!rect) return false;
|
||||
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
|
||||
},
|
||||
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType !== 'touchstart') return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
|
||||
// Back button (always available)
|
||||
if (this._hitTest(tx, ty, this._backBtnRect)) {
|
||||
this._goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this._state) {
|
||||
case ROOM_STATE.IDLE:
|
||||
if (this._hitTest(tx, ty, this._createBtnRect)) {
|
||||
this._handleCreateRoom();
|
||||
} else if (this._hitTest(tx, ty, this._joinBtnRect)) {
|
||||
this._state = ROOM_STATE.INPUT_CODE;
|
||||
this._inputCode = '';
|
||||
}
|
||||
break;
|
||||
|
||||
case ROOM_STATE.INPUT_CODE:
|
||||
// Check numpad
|
||||
for (const btn of this._numpadRects) {
|
||||
if (this._hitTest(tx, ty, btn)) {
|
||||
if (btn.value === 'del') {
|
||||
this._inputCode = this._inputCode.slice(0, -1);
|
||||
} else if (this._inputCode.length < 6) {
|
||||
this._inputCode += String(btn.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Check confirm
|
||||
if (this._inputCode.length >= 4 && this._hitTest(tx, ty, this._confirmBtnRect)) {
|
||||
this._handleJoinRoom();
|
||||
}
|
||||
break;
|
||||
|
||||
case ROOM_STATE.ERROR:
|
||||
this._state = ROOM_STATE.IDLE;
|
||||
this._errorMsg = '';
|
||||
break;
|
||||
|
||||
case ROOM_STATE.WAITING:
|
||||
// Allow going back while waiting
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
async _handleCreateRoom() {
|
||||
this._state = ROOM_STATE.CREATING;
|
||||
const nm = this._networkManager;
|
||||
|
||||
if (!nm.connected) {
|
||||
const ok = await nm.connect(this._serverUrl);
|
||||
if (!ok) {
|
||||
this._errorMsg = t('common.cannotConnect');
|
||||
this._state = ROOM_STATE.ERROR;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
nm.createRoom();
|
||||
},
|
||||
|
||||
async _handleJoinRoom() {
|
||||
this._state = ROOM_STATE.JOINING;
|
||||
const nm = this._networkManager;
|
||||
|
||||
if (!nm.connected) {
|
||||
const ok = await nm.connect(this._serverUrl);
|
||||
if (!ok) {
|
||||
this._errorMsg = t('common.cannotConnect');
|
||||
this._state = ROOM_STATE.ERROR;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
nm.joinRoom(this._inputCode);
|
||||
},
|
||||
|
||||
_goBack() {
|
||||
// Disconnect if connected
|
||||
if (this._networkManager && this._networkManager.connected) {
|
||||
this._networkManager.disconnect();
|
||||
}
|
||||
const sm = GameGlobal.sceneManager;
|
||||
sm.switchTo(SCENE.MENU);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = RoomScene;
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* SettingsScene.js
|
||||
* Settings screen with sound, music, and vibration toggles.
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
COLORS,
|
||||
SCENE,
|
||||
} = require('../base/GameGlobal');
|
||||
const { t } = require('../i18n/I18n');
|
||||
|
||||
const SettingsScene = {
|
||||
_settings: {
|
||||
soundEnabled: true,
|
||||
musicEnabled: true,
|
||||
vibrationEnabled: true,
|
||||
},
|
||||
_buttons: {},
|
||||
|
||||
enter() {
|
||||
// Load settings from storage
|
||||
try {
|
||||
const saved = wx.getStorageSync('game_settings');
|
||||
if (saved) {
|
||||
this._settings = { ...this._settings, ...JSON.parse(saved) };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Settings] Failed to load settings:', e);
|
||||
}
|
||||
this._buttons = {};
|
||||
},
|
||||
|
||||
exit() {
|
||||
// Save settings
|
||||
try {
|
||||
wx.setStorageSync('game_settings', JSON.stringify(this._settings));
|
||||
} catch (e) {
|
||||
console.warn('[Settings] Failed to save settings:', e);
|
||||
}
|
||||
},
|
||||
|
||||
update(dt) {},
|
||||
|
||||
render(ctx) {
|
||||
// Background
|
||||
ctx.fillStyle = COLORS.MENU_BG;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
const cx = SCREEN_WIDTH / 2;
|
||||
let y = 60;
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 28px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('settings.title'), cx, y);
|
||||
|
||||
y += 70;
|
||||
|
||||
// Toggle items
|
||||
const toggles = [
|
||||
{ key: 'soundEnabled', label: t('settings.sound'), icon: '🔊' },
|
||||
{ key: 'musicEnabled', label: t('settings.music'), icon: '🎵' },
|
||||
{ key: 'vibrationEnabled', label: t('settings.vibration'), icon: '📳' },
|
||||
];
|
||||
|
||||
for (const toggle of toggles) {
|
||||
this._renderToggle(ctx, cx, y, toggle);
|
||||
y += 70;
|
||||
}
|
||||
|
||||
// Back button
|
||||
y = SCREEN_HEIGHT - 80;
|
||||
this._renderBackButton(ctx, cx, y);
|
||||
},
|
||||
|
||||
_renderToggle(ctx, cx, y, toggle) {
|
||||
const w = SCREEN_WIDTH * 0.7;
|
||||
const h = 50;
|
||||
const x = cx - w / 2;
|
||||
const isOn = this._settings[toggle.key];
|
||||
|
||||
// Store button rect
|
||||
this._buttons[toggle.key] = { x, y: y - h / 2, w, h };
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#1e1e3a';
|
||||
ctx.fillRect(x, y - h / 2, w, h);
|
||||
ctx.strokeStyle = '#333366';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y - h / 2, w, h);
|
||||
|
||||
// Icon and label
|
||||
ctx.fillStyle = COLORS.HUD_TEXT;
|
||||
ctx.font = '16px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(`${toggle.icon} ${toggle.label}`, x + 15, y);
|
||||
|
||||
// Toggle switch
|
||||
const switchW = 50;
|
||||
const switchH = 26;
|
||||
const switchX = x + w - switchW - 15;
|
||||
const switchY = y - switchH / 2;
|
||||
|
||||
// Switch track
|
||||
ctx.fillStyle = isOn ? '#4CAF50' : '#555555';
|
||||
ctx.beginPath();
|
||||
ctx.arc(switchX + switchH / 2, y, switchH / 2, Math.PI / 2, Math.PI * 3 / 2);
|
||||
ctx.arc(switchX + switchW - switchH / 2, y, switchH / 2, -Math.PI / 2, Math.PI / 2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Switch knob
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.beginPath();
|
||||
const knobX = isOn ? switchX + switchW - switchH / 2 : switchX + switchH / 2;
|
||||
ctx.arc(knobX, y, switchH / 2 - 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
},
|
||||
|
||||
_renderBackButton(ctx, cx, y) {
|
||||
const w = SCREEN_WIDTH * 0.4;
|
||||
const h = 42;
|
||||
const x = cx - w / 2;
|
||||
|
||||
this._buttons['back'] = { x, y: y - h / 2, w, h };
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_BTN;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.fillRect(x, y - h / 2, w, h);
|
||||
ctx.strokeRect(x, y - h / 2, w, h);
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = 'bold 16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('common.back'), cx, y);
|
||||
},
|
||||
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType !== 'touchstart') return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
|
||||
for (const [key, rect] of Object.entries(this._buttons)) {
|
||||
if (tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h) {
|
||||
if (key === 'back') {
|
||||
GameGlobal.sceneManager.switchTo(SCENE.MENU);
|
||||
} else if (this._settings.hasOwnProperty(key)) {
|
||||
this._settings[key] = !this._settings[key];
|
||||
// Notify audio system
|
||||
GameGlobal.eventBus.emit('settings:changed', this._settings);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = SettingsScene;
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* ShopScene.js
|
||||
* Simplified shop scene for monetization-lite.
|
||||
* Shows 3 products: Ad-Free (¥18), Gold Pack (¥6), Newcomer Pack (¥1).
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
COLORS,
|
||||
SCENE,
|
||||
} = require('../base/GameGlobal');
|
||||
const { t } = require('../i18n/I18n');
|
||||
|
||||
// Layout
|
||||
const CARD_W = Math.min(SCREEN_WIDTH * 0.85, 320);
|
||||
const CARD_H = 70;
|
||||
const CARD_GAP = 12;
|
||||
const CARD_X = (SCREEN_WIDTH - CARD_W) / 2;
|
||||
const CARDS_START_Y = SCREEN_HEIGHT * 0.25;
|
||||
|
||||
const ShopScene = {
|
||||
_buttons: {},
|
||||
_message: '',
|
||||
_messageTimer: 0,
|
||||
|
||||
enter() {
|
||||
this._buttons = {};
|
||||
this._message = '';
|
||||
this._messageTimer = 0;
|
||||
this._calculateLayout();
|
||||
},
|
||||
|
||||
exit() {},
|
||||
|
||||
_calculateLayout() {
|
||||
let y = CARDS_START_Y;
|
||||
|
||||
// Ad-Free card
|
||||
this._buttons.adFree = { x: CARD_X, y, w: CARD_W, h: CARD_H };
|
||||
y += CARD_H + CARD_GAP;
|
||||
|
||||
// Gold Pack card
|
||||
this._buttons.goldPack = { x: CARD_X, y, w: CARD_W, h: CARD_H };
|
||||
y += CARD_H + CARD_GAP;
|
||||
|
||||
// Newcomer Pack card (only if available)
|
||||
this._buttons.newcomerPack = { x: CARD_X, y, w: CARD_W, h: CARD_H };
|
||||
y += CARD_H + CARD_GAP + 10;
|
||||
|
||||
// Back button
|
||||
const backW = 100;
|
||||
const backH = 36;
|
||||
this._buttons.back = { x: (SCREEN_WIDTH - backW) / 2, y, w: backW, h: backH };
|
||||
},
|
||||
|
||||
update(dt) {
|
||||
if (this._messageTimer > 0) {
|
||||
this._messageTimer -= dt;
|
||||
if (this._messageTimer <= 0) {
|
||||
this._message = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
render(ctx) {
|
||||
// Background
|
||||
ctx.fillStyle = COLORS.MENU_BG;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 24px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('shop.title') || 'Shop', SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.08);
|
||||
|
||||
// Gold balance
|
||||
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 16px Arial';
|
||||
ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.16);
|
||||
|
||||
const pm = GameGlobal.paymentManager;
|
||||
|
||||
// Ad-Free card
|
||||
const adFreePurchased = pm && pm.isAdFreePurchased();
|
||||
this._renderProductCard(ctx, this._buttons.adFree,
|
||||
t('shop.adFree') || 'Remove Ads',
|
||||
t('shop.adFreeDesc') || 'Permanently remove interstitial ads',
|
||||
adFreePurchased ? (t('shop.adFreeOwned') || 'Owned') : '¥18',
|
||||
adFreePurchased
|
||||
);
|
||||
|
||||
// Gold Pack card
|
||||
this._renderProductCard(ctx, this._buttons.goldPack,
|
||||
t('shop.goldPack') || 'Gold Pack',
|
||||
t('shop.goldPackDesc') || '1000 Gold',
|
||||
'¥6',
|
||||
false
|
||||
);
|
||||
|
||||
// Newcomer Pack card
|
||||
const newcomerAvailable = pm && pm.isNewcomerPackAvailable();
|
||||
if (newcomerAvailable) {
|
||||
const remainingMs = pm.getNewcomerPackRemainingMs();
|
||||
const hours = Math.floor(remainingMs / (60 * 60 * 1000));
|
||||
const mins = Math.floor((remainingMs % (60 * 60 * 1000)) / (60 * 1000));
|
||||
const timeStr = `${hours}h ${mins}m`;
|
||||
|
||||
this._renderProductCard(ctx, this._buttons.newcomerPack,
|
||||
t('shop.newcomerPack') || 'Newcomer Pack',
|
||||
`${t('shop.newcomerPackDesc') || '500 Gold'} (⏰ ${timeStr})`,
|
||||
'¥1',
|
||||
false,
|
||||
'#FF9800' // highlight color for limited time
|
||||
);
|
||||
} else {
|
||||
// Show expired/purchased state
|
||||
this._renderProductCard(ctx, this._buttons.newcomerPack,
|
||||
t('shop.newcomerPack') || 'Newcomer Pack',
|
||||
t('shop.newcomerExpired') || 'Expired',
|
||||
'--',
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Back button
|
||||
this._renderButton(ctx, this._buttons.back, t('common.back') || '← Back', '#666666');
|
||||
|
||||
// Toast message
|
||||
if (this._message) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
const msgW = 200;
|
||||
const msgH = 36;
|
||||
ctx.fillRect(SCREEN_WIDTH / 2 - msgW / 2, SCREEN_HEIGHT * 0.92 - msgH / 2, msgW, msgH);
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(this._message, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.92);
|
||||
}
|
||||
},
|
||||
|
||||
_renderProductCard(ctx, rect, title, desc, priceLabel, disabled, highlightColor) {
|
||||
if (!rect) return;
|
||||
|
||||
// Card background
|
||||
ctx.fillStyle = disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.06)';
|
||||
ctx.strokeStyle = highlightColor || (disabled ? '#333333' : '#555555');
|
||||
ctx.lineWidth = highlightColor ? 2 : 1;
|
||||
|
||||
const r = 8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rect.x + r, rect.y);
|
||||
ctx.lineTo(rect.x + rect.w - r, rect.y);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
|
||||
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
|
||||
ctx.lineTo(rect.x + r, rect.y + rect.h);
|
||||
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
|
||||
ctx.lineTo(rect.x, rect.y + r);
|
||||
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Title (left aligned)
|
||||
ctx.fillStyle = disabled ? '#666666' : '#FFFFFF';
|
||||
ctx.font = 'bold 15px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(title, rect.x + 15, rect.y + rect.h * 0.35);
|
||||
|
||||
// Description
|
||||
ctx.fillStyle = disabled ? '#444444' : '#AAAAAA';
|
||||
ctx.font = '11px Arial';
|
||||
ctx.fillText(desc, rect.x + 15, rect.y + rect.h * 0.65);
|
||||
|
||||
// Price (right aligned)
|
||||
ctx.fillStyle = disabled ? '#444444' : (highlightColor || '#FFD700');
|
||||
ctx.font = 'bold 16px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(priceLabel, rect.x + rect.w - 15, rect.y + rect.h / 2);
|
||||
},
|
||||
|
||||
_renderButton(ctx, rect, label, color) {
|
||||
if (!rect) return;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
const r = 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rect.x + r, rect.y);
|
||||
ctx.lineTo(rect.x + rect.w - r, rect.y);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
|
||||
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
|
||||
ctx.lineTo(rect.x + r, rect.y + rect.h);
|
||||
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
|
||||
ctx.lineTo(rect.x, rect.y + r);
|
||||
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
},
|
||||
|
||||
_hitTest(tx, ty, rect) {
|
||||
if (!rect) return false;
|
||||
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
|
||||
},
|
||||
|
||||
_showMessage(msg) {
|
||||
this._message = msg;
|
||||
this._messageTimer = 2;
|
||||
},
|
||||
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType !== 'touchstart') return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
const pm = GameGlobal.paymentManager;
|
||||
|
||||
// Ad-Free
|
||||
if (this._hitTest(tx, ty, this._buttons.adFree)) {
|
||||
if (pm && !pm.isAdFreePurchased()) {
|
||||
pm.purchaseAdFree((result) => {
|
||||
if (result.success) {
|
||||
this._showMessage('✅ Ad-Free activated!');
|
||||
} else {
|
||||
this._showMessage(result.error || 'Purchase failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Gold Pack
|
||||
if (this._hitTest(tx, ty, this._buttons.goldPack)) {
|
||||
if (pm) {
|
||||
pm.purchaseGoldPack((result) => {
|
||||
if (result.success) {
|
||||
this._showMessage('✅ +1000 Gold!');
|
||||
} else {
|
||||
this._showMessage(result.error || 'Purchase failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Newcomer Pack
|
||||
if (this._hitTest(tx, ty, this._buttons.newcomerPack)) {
|
||||
if (pm && pm.isNewcomerPackAvailable()) {
|
||||
pm.purchaseNewcomerPack((result) => {
|
||||
if (result.success) {
|
||||
this._showMessage('✅ +500 Gold!');
|
||||
} else {
|
||||
this._showMessage(result.error || 'Purchase failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Back
|
||||
if (this._hitTest(tx, ty, this._buttons.back)) {
|
||||
GameGlobal.sceneManager.switchTo(SCENE.MENU);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = ShopScene;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* TeamResultScene.js
|
||||
* 3v3 Team match result screen.
|
||||
* Shows winner, per-player stats (kills/deaths/assists/base damage),
|
||||
* base HP summary, and options to rematch or return to menu.
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
COLORS,
|
||||
SCENE,
|
||||
NET_MSG,
|
||||
} = require('../base/GameGlobal');
|
||||
const { t } = require('../i18n/I18n');
|
||||
|
||||
// Layout
|
||||
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.35, 180);
|
||||
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
|
||||
const BTN_GAP = 14;
|
||||
const CENTER_X = SCREEN_WIDTH / 2;
|
||||
|
||||
// Team colors
|
||||
const TEAM_A_COLOR = '#4A90D9';
|
||||
const TEAM_B_COLOR = '#E94560';
|
||||
|
||||
const TeamResultScene = {
|
||||
_winner: '',
|
||||
_winReason: '',
|
||||
_myTeam: '',
|
||||
_didWin: false,
|
||||
_teamABaseHp: 0,
|
||||
_teamBBaseHp: 0,
|
||||
_stats: {},
|
||||
_players: [],
|
||||
_elapsedTime: 0,
|
||||
_teamId: '',
|
||||
_animTimer: 0,
|
||||
_battleMode: '3v3', // '1v1' or '3v3'
|
||||
|
||||
// Rematch state
|
||||
_rematchRequested: false,
|
||||
_rematchReadyCount: 0,
|
||||
_rematchTotalCount: 0,
|
||||
_networkManager: null,
|
||||
_unsubscribers: [],
|
||||
|
||||
// Button rects
|
||||
_rematchBtnRect: null,
|
||||
_menuBtnRect: null,
|
||||
_adDoubleBtnRect: null,
|
||||
|
||||
// Ad state
|
||||
_adWatched: false,
|
||||
_goldReward: 0,
|
||||
|
||||
// Scroll state for player list
|
||||
_scrollY: 0,
|
||||
|
||||
enter(params) {
|
||||
this._winner = (params && params.winner) || '';
|
||||
this._winReason = (params && params.winReason) || 'base_destroyed';
|
||||
this._myTeam = (params && params.myTeam) || 'A';
|
||||
this._didWin = (params && params.didWin) || false;
|
||||
this._teamABaseHp = (params && params.teamABaseHp) || 0;
|
||||
this._teamBBaseHp = (params && params.teamBBaseHp) || 0;
|
||||
this._stats = (params && params.stats) || {};
|
||||
this._players = (params && params.players) || [];
|
||||
this._elapsedTime = (params && params.elapsedTime) || 0;
|
||||
this._teamId = (params && params.teamId) || '';
|
||||
this._animTimer = 0;
|
||||
this._scrollY = 0;
|
||||
this._battleMode = (params && params.battleMode) || '3v3';
|
||||
this._rematchRequested = false;
|
||||
this._rematchReadyCount = 0;
|
||||
this._rematchTotalCount = 0;
|
||||
this._networkManager = GameGlobal.networkManager;
|
||||
this._adWatched = false;
|
||||
this._goldReward = 0;
|
||||
|
||||
// Calculate and award gold
|
||||
this._calculateAndAwardGold();
|
||||
|
||||
this._setupNetworkEvents();
|
||||
|
||||
const btnY = SCREEN_HEIGHT * 0.88;
|
||||
this._rematchBtnRect = {
|
||||
x: CENTER_X - BTN_WIDTH - BTN_GAP / 2,
|
||||
y: btnY,
|
||||
w: BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
this._menuBtnRect = {
|
||||
x: CENTER_X + BTN_GAP / 2,
|
||||
y: btnY,
|
||||
w: BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
|
||||
// Double reward ad button (above rematch/menu buttons)
|
||||
const adBtnY = btnY - BTN_HEIGHT - BTN_GAP;
|
||||
this._adDoubleBtnRect = {
|
||||
x: CENTER_X - BTN_WIDTH * 0.75,
|
||||
y: adBtnY,
|
||||
w: BTN_WIDTH * 1.5,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
},
|
||||
|
||||
exit() {
|
||||
this._cleanupNetworkEvents();
|
||||
},
|
||||
|
||||
_setupNetworkEvents() {
|
||||
this._cleanupNetworkEvents();
|
||||
const nm = this._networkManager;
|
||||
if (!nm) return;
|
||||
|
||||
const unsubs = [];
|
||||
|
||||
// Listen for rematch ready updates
|
||||
unsubs.push(nm.on(NET_MSG.REMATCH_READY, (data) => {
|
||||
console.log('[TeamResultScene] REMATCH_READY received:', JSON.stringify(data));
|
||||
this._rematchReadyCount = data.readyCount || 0;
|
||||
this._rematchTotalCount = data.totalCount || 0;
|
||||
}));
|
||||
|
||||
// Listen for game start (rematch accepted, new game starting)
|
||||
unsubs.push(nm.on(NET_MSG.GAME_START, (data) => {
|
||||
console.log('[TeamResultScene] GAME_START received for rematch');
|
||||
this._startRematchGame(data);
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.TEAM_GAME_START, (data) => {
|
||||
console.log('[TeamResultScene] TEAM_GAME_START received for rematch');
|
||||
this._startRematchGame(data);
|
||||
}));
|
||||
|
||||
this._unsubscribers = unsubs;
|
||||
},
|
||||
|
||||
_cleanupNetworkEvents() {
|
||||
for (const unsub of this._unsubscribers) {
|
||||
if (typeof unsub === 'function') unsub();
|
||||
}
|
||||
this._unsubscribers = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate and award gold for team match.
|
||||
* @private
|
||||
*/
|
||||
_calculateAndAwardGold() {
|
||||
let gold = 50; // Base reward per requirements
|
||||
|
||||
// Find local player stats
|
||||
const localPlayer = this._players.find(p => p.isLocal);
|
||||
if (localPlayer) {
|
||||
const stats = this._stats[localPlayer.playerId] || {};
|
||||
gold += (stats.kills || 0) * 10;
|
||||
gold += (stats.assists || 0) * 5;
|
||||
}
|
||||
|
||||
// Victory bonus
|
||||
if (this._didWin) {
|
||||
gold += 50;
|
||||
}
|
||||
|
||||
this._goldReward = gold;
|
||||
|
||||
// Award gold
|
||||
if (gold > 0 && GameGlobal.currencyManager) {
|
||||
GameGlobal.currencyManager.addGold(gold);
|
||||
}
|
||||
},
|
||||
|
||||
_startRematchGame(data) {
|
||||
const sm = GameGlobal.sceneManager;
|
||||
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
|
||||
const TeamGameScene = require('./TeamGameScene');
|
||||
sm.register(SCENE.TEAM_GAME, TeamGameScene);
|
||||
}
|
||||
|
||||
const myPlayerId = this._networkManager ? this._networkManager.playerId : 'local';
|
||||
|
||||
sm.switchTo(SCENE.TEAM_GAME, {
|
||||
teamId: data.roomId || this._teamId,
|
||||
roomId: data.roomId || this._teamId,
|
||||
mapId: data.mapId || null,
|
||||
teamA: data.teamA || [],
|
||||
teamB: data.teamB || [],
|
||||
teamABaseHp: data.teamABaseHp,
|
||||
teamBBaseHp: data.teamBBaseHp,
|
||||
myPlayerId,
|
||||
battleMode: data.battleMode || this._battleMode,
|
||||
});
|
||||
},
|
||||
|
||||
update(dt) {
|
||||
this._animTimer += dt;
|
||||
},
|
||||
|
||||
render(ctx) {
|
||||
// Background
|
||||
ctx.fillStyle = COLORS.MENU_BG;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
// Top accent bar
|
||||
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
|
||||
gradient.addColorStop(0, '#0f3460');
|
||||
gradient.addColorStop(0.5, '#e94560');
|
||||
gradient.addColorStop(1, '#0f3460');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
|
||||
|
||||
// Title
|
||||
ctx.font = 'bold 16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.fillText(t('teamResult.title'), CENTER_X, SCREEN_HEIGHT * 0.05);
|
||||
|
||||
// Winner announcement with pulsing effect
|
||||
let resultText, resultColor;
|
||||
if (this._didWin) {
|
||||
resultText = t('teamResult.victory');
|
||||
resultColor = '#00FF00';
|
||||
} else {
|
||||
resultText = t('teamResult.defeat');
|
||||
resultColor = '#FF4444';
|
||||
}
|
||||
|
||||
const scale = 1 + Math.sin(this._animTimer * 3) * 0.05;
|
||||
ctx.save();
|
||||
ctx.translate(CENTER_X, SCREEN_HEIGHT * 0.13);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fillStyle = resultColor;
|
||||
ctx.font = 'bold 28px Arial';
|
||||
ctx.fillText(resultText, 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// Base HP summary
|
||||
const hpY = SCREEN_HEIGHT * 0.2;
|
||||
ctx.font = 'bold 12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
ctx.fillStyle = TEAM_A_COLOR;
|
||||
ctx.fillText(t('teamResult.teamAHp', { hp: this._teamABaseHp }), CENTER_X - 70, hpY);
|
||||
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.fillText('vs', CENTER_X, hpY);
|
||||
|
||||
ctx.fillStyle = TEAM_B_COLOR;
|
||||
ctx.fillText(t('teamResult.teamBHp', { hp: this._teamBBaseHp }), CENTER_X + 70, hpY);
|
||||
|
||||
// Win reason
|
||||
let reasonText = t('teamResult.baseDestroyed');
|
||||
if (this._winReason === 'disconnected') {
|
||||
reasonText = t('teamResult.disconnectedReason');
|
||||
}
|
||||
ctx.fillStyle = '#888888';
|
||||
ctx.font = '10px Arial';
|
||||
ctx.fillText(reasonText, CENTER_X, hpY + 16);
|
||||
|
||||
// Player stats table
|
||||
this._renderStatsTable(ctx);
|
||||
|
||||
// Double reward ad button
|
||||
if (!this._adWatched) {
|
||||
this._drawButton(ctx, this._adDoubleBtnRect, t('result.adDouble') || '📺 Double Rewards');
|
||||
}
|
||||
|
||||
// Gold reward display
|
||||
if (this._goldReward > 0) {
|
||||
const goldY = this._adDoubleBtnRect ? this._adDoubleBtnRect.y - 20 : SCREEN_HEIGHT * 0.82;
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
const goldLabel = this._adWatched
|
||||
? `🪙 +${this._goldReward} (${t('result.doubled') || '2x!'})`
|
||||
: `🪙 +${this._goldReward}`;
|
||||
ctx.fillText(goldLabel, CENTER_X, goldY);
|
||||
}
|
||||
|
||||
// Buttons
|
||||
if (this._rematchRequested) {
|
||||
// Show waiting state on rematch button
|
||||
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
|
||||
this._drawButton(ctx, this._rematchBtnRect,
|
||||
t('teamResult.rematchWaiting', { ready: this._rematchReadyCount, total: this._rematchTotalCount }) + dots,
|
||||
true);
|
||||
} else {
|
||||
this._drawButton(ctx, this._rematchBtnRect, t('teamResult.rematch'));
|
||||
}
|
||||
this._drawButton(ctx, this._menuBtnRect, t('teamResult.backMenu'));
|
||||
},
|
||||
|
||||
_renderStatsTable(ctx) {
|
||||
const tableY = SCREEN_HEIGHT * 0.28;
|
||||
const tableW = Math.min(SCREEN_WIDTH * 0.92, 400);
|
||||
const tableX = CENTER_X - tableW / 2;
|
||||
const rowH = 18;
|
||||
const headerH = 22;
|
||||
|
||||
// Sort players: Team A first, then Team B; within team sort by kills desc
|
||||
const teamAPlayers = this._players.filter(p => p.team === 'A');
|
||||
const teamBPlayers = this._players.filter(p => p.team === 'B');
|
||||
|
||||
const sortByKills = (a, b) => {
|
||||
const sa = this._stats[a.playerId] || {};
|
||||
const sb = this._stats[b.playerId] || {};
|
||||
return (sb.kills || 0) - (sa.kills || 0);
|
||||
};
|
||||
teamAPlayers.sort(sortByKills);
|
||||
teamBPlayers.sort(sortByKills);
|
||||
|
||||
// Column positions
|
||||
const cols = {
|
||||
name: tableX + tableW * 0.22,
|
||||
kills: tableX + tableW * 0.48,
|
||||
deaths: tableX + tableW * 0.60,
|
||||
assists: tableX + tableW * 0.72,
|
||||
baseDmg: tableX + tableW * 0.88,
|
||||
};
|
||||
|
||||
// Render Team A section
|
||||
let y = tableY;
|
||||
|
||||
// Team A header
|
||||
ctx.fillStyle = 'rgba(74, 144, 217, 0.15)';
|
||||
ctx.fillRect(tableX, y, tableW, headerH);
|
||||
ctx.fillStyle = TEAM_A_COLOR;
|
||||
ctx.font = 'bold 11px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('teamResult.teamAHeader') + (this._myTeam === 'A' ? t('teamResult.myTeamSuffix') : ''), tableX + 6, y + headerH / 2);
|
||||
|
||||
// Column headers
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '9px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('teamResult.player'), cols.name, y + headerH / 2);
|
||||
ctx.fillText(t('teamResult.k'), cols.kills, y + headerH / 2);
|
||||
ctx.fillText(t('teamResult.d'), cols.deaths, y + headerH / 2);
|
||||
ctx.fillText(t('teamResult.a'), cols.assists, y + headerH / 2);
|
||||
ctx.fillText(t('teamResult.dmg'), cols.baseDmg, y + headerH / 2);
|
||||
y += headerH;
|
||||
|
||||
// Team A players
|
||||
for (const player of teamAPlayers) {
|
||||
const stats = this._stats[player.playerId] || {};
|
||||
const isLocal = player.isLocal;
|
||||
|
||||
ctx.fillStyle = isLocal ? 'rgba(255, 215, 0, 0.1)' : 'rgba(255,255,255,0.02)';
|
||||
ctx.fillRect(tableX, y, tableW, rowH);
|
||||
|
||||
// Player name
|
||||
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
|
||||
ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
const name = player.isBot ? t('teamResult.bot') : (player.playerId.length > 10 ? player.playerId.substring(0, 10) + '..' : player.playerId);
|
||||
ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2);
|
||||
|
||||
// Stats
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '10px Arial';
|
||||
ctx.fillText(String(stats.kills || 0), cols.kills, y + rowH / 2);
|
||||
ctx.fillText(String(stats.deaths || 0), cols.deaths, y + rowH / 2);
|
||||
ctx.fillText(String(stats.assists || 0), cols.assists, y + rowH / 2);
|
||||
ctx.fillText(String(stats.baseDamage || 0), cols.baseDmg, y + rowH / 2);
|
||||
|
||||
y += rowH;
|
||||
}
|
||||
|
||||
// Separator
|
||||
y += 4;
|
||||
|
||||
// Team B header
|
||||
ctx.fillStyle = 'rgba(233, 69, 96, 0.15)';
|
||||
ctx.fillRect(tableX, y, tableW, headerH);
|
||||
ctx.fillStyle = TEAM_B_COLOR;
|
||||
ctx.font = 'bold 11px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(t('teamResult.teamBHeader') + (this._myTeam === 'B' ? t('teamResult.myTeamSuffix') : ''), tableX + 6, y + headerH / 2);
|
||||
|
||||
// Column headers
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '9px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('teamResult.player'), cols.name, y + headerH / 2);
|
||||
ctx.fillText(t('teamResult.k'), cols.kills, y + headerH / 2);
|
||||
ctx.fillText(t('teamResult.d'), cols.deaths, y + headerH / 2);
|
||||
ctx.fillText(t('teamResult.a'), cols.assists, y + headerH / 2);
|
||||
ctx.fillText(t('teamResult.dmg'), cols.baseDmg, y + headerH / 2);
|
||||
y += headerH;
|
||||
|
||||
// Team B players
|
||||
for (const player of teamBPlayers) {
|
||||
const stats = this._stats[player.playerId] || {};
|
||||
const isLocal = player.isLocal;
|
||||
|
||||
ctx.fillStyle = isLocal ? 'rgba(255, 215, 0, 0.1)' : 'rgba(255,255,255,0.02)';
|
||||
ctx.fillRect(tableX, y, tableW, rowH);
|
||||
|
||||
// Player name
|
||||
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
|
||||
ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
const name = player.isBot ? t('teamResult.bot') : (player.playerId.length > 10 ? player.playerId.substring(0, 10) + '..' : player.playerId);
|
||||
ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2);
|
||||
|
||||
// Stats
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '10px Arial';
|
||||
ctx.fillText(String(stats.kills || 0), cols.kills, y + rowH / 2);
|
||||
ctx.fillText(String(stats.deaths || 0), cols.deaths, y + rowH / 2);
|
||||
ctx.fillText(String(stats.assists || 0), cols.assists, y + rowH / 2);
|
||||
ctx.fillText(String(stats.baseDamage || 0), cols.baseDmg, y + rowH / 2);
|
||||
|
||||
y += rowH;
|
||||
}
|
||||
|
||||
// Elapsed time display
|
||||
if (this._elapsedTime > 0) {
|
||||
y += 8;
|
||||
const minutes = Math.floor(this._elapsedTime / 60);
|
||||
const seconds = this._elapsedTime % 60;
|
||||
ctx.fillStyle = '#666666';
|
||||
ctx.font = '10px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(
|
||||
t('teamResult.duration', { time: `${minutes}:${seconds.toString().padStart(2, '0')}` }),
|
||||
CENTER_X,
|
||||
y
|
||||
);
|
||||
}
|
||||
|
||||
// MVP highlight (player with most kills)
|
||||
const allPlayers = [...teamAPlayers, ...teamBPlayers];
|
||||
let mvp = null;
|
||||
let maxKills = 0;
|
||||
for (const p of allPlayers) {
|
||||
const s = this._stats[p.playerId] || {};
|
||||
if ((s.kills || 0) > maxKills) {
|
||||
maxKills = s.kills || 0;
|
||||
mvp = p;
|
||||
}
|
||||
}
|
||||
if (mvp && maxKills > 0) {
|
||||
y += 16;
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 11px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
const mvpName = mvp.isBot ? t('teamResult.bot').replace('🤖 ', '') : mvp.playerId;
|
||||
ctx.fillText(t('teamResult.mvp', { name: mvpName, kills: maxKills }), CENTER_X, y);
|
||||
}
|
||||
|
||||
// Rank points change
|
||||
y += 18;
|
||||
const basePoints = 20;
|
||||
const mvpBonus = 5;
|
||||
if (this._didWin) {
|
||||
const isMvp = mvp && mvp.isLocal;
|
||||
const points = basePoints + (isMvp ? mvpBonus : 0);
|
||||
ctx.fillStyle = '#00FF00';
|
||||
ctx.font = 'bold 11px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('teamResult.rankUp', { points }) + (isMvp ? t('teamResult.mvpBonus') : ''), CENTER_X, y);
|
||||
} else {
|
||||
ctx.fillStyle = '#FF6347';
|
||||
ctx.font = 'bold 11px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('teamResult.rankDown', { points: basePoints }), CENTER_X, y);
|
||||
}
|
||||
},
|
||||
|
||||
_drawButton(ctx, rect, label) {
|
||||
if (!rect) return;
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_BTN;
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
const r = 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rect.x + r, rect.y);
|
||||
ctx.lineTo(rect.x + rect.w - r, rect.y);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
|
||||
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
|
||||
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
|
||||
ctx.lineTo(rect.x + r, rect.y + rect.h);
|
||||
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
|
||||
ctx.lineTo(rect.x, rect.y + r);
|
||||
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
},
|
||||
|
||||
_hitTest(tx, ty, rect) {
|
||||
if (!rect) return false;
|
||||
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
|
||||
},
|
||||
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType !== 'touchstart') return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
|
||||
// Double reward ad button
|
||||
if (!this._adWatched && this._hitTest(tx, ty, this._adDoubleBtnRect)) {
|
||||
const AdManager = require('../managers/AdManager');
|
||||
if (GameGlobal.adManager &&
|
||||
GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.DOUBLE_REWARD)) {
|
||||
GameGlobal.adManager.showRewardedVideoForScene(
|
||||
AdManager.AD_SCENE.DOUBLE_REWARD,
|
||||
(completed) => {
|
||||
if (completed) {
|
||||
this._adWatched = true;
|
||||
// Award bonus gold (double the original reward)
|
||||
if (this._goldReward > 0 && GameGlobal.currencyManager) {
|
||||
GameGlobal.currencyManager.addGold(this._goldReward);
|
||||
this._goldReward *= 2; // Update display
|
||||
}
|
||||
console.log('[TeamResultScene] Double reward ad completed');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Rematch button -> send rematch request to server (reuse room)
|
||||
if (this._hitTest(tx, ty, this._rematchBtnRect)) {
|
||||
if (!this._rematchRequested) {
|
||||
this._rematchRequested = true;
|
||||
const nm = this._networkManager;
|
||||
console.log(`[TeamResultScene] Rematch clicked. nm=${!!nm}, connected=${nm ? nm.connected : 'N/A'}, teamId=${this._teamId}`);
|
||||
if (nm && nm.connected) {
|
||||
nm.send(NET_MSG.REMATCH, { teamId: this._teamId });
|
||||
console.log('[TeamResultScene] Rematch request sent');
|
||||
} else {
|
||||
// Not connected, fall back to creating a new room
|
||||
this._rematchRequested = false;
|
||||
const sm = GameGlobal.sceneManager;
|
||||
if (this._battleMode === '1v1') {
|
||||
if (!sm._scenes.has(SCENE.PVP_ROOM)) {
|
||||
const RoomScene = require('./RoomScene');
|
||||
sm.register(SCENE.PVP_ROOM, RoomScene);
|
||||
}
|
||||
sm.switchTo(SCENE.PVP_ROOM);
|
||||
} else {
|
||||
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
|
||||
const TeamRoomScene = require('./TeamRoomScene');
|
||||
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
|
||||
}
|
||||
sm.switchTo(SCENE.TEAM_ROOM);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Menu button -> disconnect and go to menu
|
||||
if (this._hitTest(tx, ty, this._menuBtnRect)) {
|
||||
// Show interstitial ad when leaving
|
||||
if (GameGlobal.adManager) {
|
||||
GameGlobal.adManager.showInterstitial();
|
||||
}
|
||||
if (GameGlobal.networkManager && GameGlobal.networkManager.connected) {
|
||||
GameGlobal.networkManager.disconnect();
|
||||
}
|
||||
const sm = GameGlobal.sceneManager;
|
||||
sm.switchTo(SCENE.MENU);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = TeamResultScene;
|
||||
@@ -0,0 +1,832 @@
|
||||
/**
|
||||
* TeamRoomScene.js
|
||||
* 3v3 Team room UI scene.
|
||||
* Supports team creation, joining, ready state, leader controls,
|
||||
* matchmaking, and WeChat friend invitation.
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
COLORS,
|
||||
SCENE,
|
||||
NET_MSG,
|
||||
TEAM_SIZE,
|
||||
SERVER_URL,
|
||||
} = require('../base/GameGlobal');
|
||||
const { t } = require('../i18n/I18n');
|
||||
|
||||
// ============================================================
|
||||
// Layout Constants
|
||||
// ============================================================
|
||||
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.35, 180);
|
||||
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
|
||||
const BTN_GAP = 10;
|
||||
const CENTER_X = SCREEN_WIDTH / 2;
|
||||
const SLOT_WIDTH = Math.min(SCREEN_WIDTH * 0.15, 80);
|
||||
const SLOT_HEIGHT = Math.min(SCREEN_HEIGHT * 0.18, 90);
|
||||
const SLOT_GAP = 8;
|
||||
|
||||
// ============================================================
|
||||
// Team Room States
|
||||
// ============================================================
|
||||
const TEAM_STATE = {
|
||||
MODE_SELECT: 'mode_select', // Choose: create team or solo match
|
||||
JOINING: 'joining', // Auto-joining a team from invite
|
||||
FORMING: 'forming', // Team room, waiting for members
|
||||
MATCHING: 'matching', // In matchmaking queue
|
||||
COUNTDOWN: 'countdown', // Match found, counting down
|
||||
ERROR: 'error', // Error state
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Team Room Scene
|
||||
// ============================================================
|
||||
const TeamRoomScene = {
|
||||
_state: TEAM_STATE.MODE_SELECT,
|
||||
_teamData: null, // { teamId, state, leaderId, teamA, teamB }
|
||||
_errorMsg: '',
|
||||
_animTimer: 0,
|
||||
_matchTimer: 0, // Seconds elapsed in matching
|
||||
_countdown: 3,
|
||||
_countdownTimer: 0,
|
||||
_networkManager: null,
|
||||
_unsubscribers: [],
|
||||
_isLeader: false,
|
||||
_myPlayerId: null,
|
||||
|
||||
// Server URL (from global config)
|
||||
_serverUrl: SERVER_URL,
|
||||
|
||||
// Button rects
|
||||
_createTeamBtnRect: null,
|
||||
_soloMatchBtnRect: null,
|
||||
_backBtnRect: null,
|
||||
_inviteBtnRect: null,
|
||||
_matchBtnRect: null,
|
||||
_readyBtnRect: null,
|
||||
_disbandBtnRect: null,
|
||||
_leaveBtnRect: null,
|
||||
_cancelMatchBtnRect: null,
|
||||
_slotRects: [],
|
||||
_kickBtnRects: [],
|
||||
|
||||
enter(params) {
|
||||
this._state = TEAM_STATE.MODE_SELECT;
|
||||
this._teamData = null;
|
||||
this._errorMsg = '';
|
||||
this._animTimer = 0;
|
||||
this._matchTimer = 0;
|
||||
this._countdown = 3;
|
||||
this._countdownTimer = 0;
|
||||
this._networkManager = GameGlobal.networkManager;
|
||||
this._myPlayerId = this._networkManager ? this._networkManager.playerId : 'player_' + Date.now();
|
||||
this._isLeader = false;
|
||||
|
||||
this._buildLayout();
|
||||
// Setup network events BEFORE auto-join so listeners are ready
|
||||
this._setupNetworkEvents();
|
||||
|
||||
// If entering with a teamId (from invite card), auto-join
|
||||
if (params && params.teamId) {
|
||||
this._autoJoinTeam(params.teamId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update share content so that any share from the top-right menu
|
||||
* always carries the current teamId.
|
||||
* @private
|
||||
*/
|
||||
_updateShareContent() {
|
||||
if (!this._teamData || !this._teamData.teamId) return;
|
||||
const shareManager = GameGlobal.shareManager;
|
||||
if (shareManager) {
|
||||
shareManager.setShareContent({
|
||||
title: t('teamRoom.shareTitle'),
|
||||
imageUrl: '',
|
||||
query: `teamId=${this._teamData.teamId}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
exit() {
|
||||
this._cleanupNetworkEvents();
|
||||
// Reset share content when leaving team room
|
||||
const shareManager = GameGlobal.shareManager;
|
||||
if (shareManager) {
|
||||
shareManager.resetShareContent();
|
||||
}
|
||||
},
|
||||
|
||||
_buildLayout() {
|
||||
const modeY = SCREEN_HEIGHT * 0.4;
|
||||
|
||||
this._createTeamBtnRect = {
|
||||
x: CENTER_X - BTN_WIDTH / 2,
|
||||
y: modeY,
|
||||
w: BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
this._soloMatchBtnRect = {
|
||||
x: CENTER_X - BTN_WIDTH / 2,
|
||||
y: modeY + BTN_HEIGHT + BTN_GAP,
|
||||
w: BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
this._backBtnRect = {
|
||||
x: 10,
|
||||
y: 10,
|
||||
w: 60,
|
||||
h: 30,
|
||||
};
|
||||
|
||||
// Team member slots (5 slots in a row)
|
||||
const totalSlotsWidth = TEAM_SIZE * SLOT_WIDTH + (TEAM_SIZE - 1) * SLOT_GAP;
|
||||
const slotsStartX = CENTER_X - totalSlotsWidth / 2;
|
||||
const slotsY = SCREEN_HEIGHT * 0.25;
|
||||
|
||||
this._slotRects = [];
|
||||
this._kickBtnRects = [];
|
||||
for (let i = 0; i < TEAM_SIZE; i++) {
|
||||
const x = slotsStartX + i * (SLOT_WIDTH + SLOT_GAP);
|
||||
this._slotRects.push({
|
||||
x, y: slotsY, w: SLOT_WIDTH, h: SLOT_HEIGHT,
|
||||
});
|
||||
// Kick button (small X at top-right of slot)
|
||||
this._kickBtnRects.push({
|
||||
x: x + SLOT_WIDTH - 18, y: slotsY + 2, w: 16, h: 16,
|
||||
});
|
||||
}
|
||||
|
||||
// Action buttons (below slots)
|
||||
const actionY = SCREEN_HEIGHT * 0.58;
|
||||
const smallBtnW = BTN_WIDTH * 0.8;
|
||||
|
||||
this._inviteBtnRect = {
|
||||
x: CENTER_X - smallBtnW - BTN_GAP / 2,
|
||||
y: actionY,
|
||||
w: smallBtnW,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
this._matchBtnRect = {
|
||||
x: CENTER_X + BTN_GAP / 2,
|
||||
y: actionY,
|
||||
w: smallBtnW,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
this._readyBtnRect = {
|
||||
x: CENTER_X - smallBtnW / 2,
|
||||
y: actionY,
|
||||
w: smallBtnW,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
this._disbandBtnRect = {
|
||||
x: CENTER_X - smallBtnW - BTN_GAP / 2,
|
||||
y: actionY + BTN_HEIGHT + BTN_GAP,
|
||||
w: smallBtnW,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
this._leaveBtnRect = {
|
||||
x: CENTER_X - smallBtnW / 2,
|
||||
y: actionY + BTN_HEIGHT + BTN_GAP,
|
||||
w: smallBtnW,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
this._cancelMatchBtnRect = {
|
||||
x: CENTER_X - BTN_WIDTH / 2,
|
||||
y: SCREEN_HEIGHT * 0.7,
|
||||
w: BTN_WIDTH,
|
||||
h: BTN_HEIGHT,
|
||||
};
|
||||
},
|
||||
|
||||
_setupNetworkEvents() {
|
||||
this._cleanupNetworkEvents();
|
||||
const nm = this._networkManager;
|
||||
if (!nm) return;
|
||||
|
||||
const unsubs = [];
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
|
||||
this._teamData = data;
|
||||
this._isLeader = data.leaderId === this._myPlayerId;
|
||||
|
||||
if (data.state === 'forming') {
|
||||
this._state = TEAM_STATE.FORMING;
|
||||
} else if (data.state === 'matching') {
|
||||
this._state = TEAM_STATE.MATCHING;
|
||||
}
|
||||
|
||||
// Keep share content up-to-date with current teamId
|
||||
this._updateShareContent();
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.TEAM_DISBAND, (data) => {
|
||||
this._teamData = null;
|
||||
this._state = TEAM_STATE.MODE_SELECT;
|
||||
if (data.reason === 'kicked') {
|
||||
this._errorMsg = t('common.kicked');
|
||||
this._state = TEAM_STATE.ERROR;
|
||||
}
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.MATCH_FOUND, () => {
|
||||
this._state = TEAM_STATE.COUNTDOWN;
|
||||
this._countdown = 3;
|
||||
this._countdownTimer = 0;
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.TEAM_GAME_START, (data) => {
|
||||
this._startTeamGame(data);
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
|
||||
this._errorMsg = data.message || 'Unknown error';
|
||||
// Only switch to error state if not already in game transition
|
||||
if (this._state !== TEAM_STATE.COUNTDOWN) {
|
||||
this._state = TEAM_STATE.ERROR;
|
||||
}
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on('error', () => {
|
||||
this._errorMsg = t('common.connectFailed');
|
||||
this._state = TEAM_STATE.ERROR;
|
||||
}));
|
||||
|
||||
unsubs.push(nm.on('disconnected', () => {
|
||||
if (this._state !== TEAM_STATE.MODE_SELECT) {
|
||||
this._errorMsg = t('common.disconnected');
|
||||
this._state = TEAM_STATE.ERROR;
|
||||
}
|
||||
}));
|
||||
|
||||
this._unsubscribers = unsubs;
|
||||
},
|
||||
|
||||
_cleanupNetworkEvents() {
|
||||
for (const unsub of this._unsubscribers) {
|
||||
if (typeof unsub === 'function') unsub();
|
||||
}
|
||||
this._unsubscribers = [];
|
||||
},
|
||||
|
||||
update(dt) {
|
||||
this._animTimer += dt;
|
||||
|
||||
if (this._state === TEAM_STATE.MATCHING) {
|
||||
this._matchTimer += dt;
|
||||
}
|
||||
|
||||
if (this._state === TEAM_STATE.COUNTDOWN) {
|
||||
this._countdownTimer += dt;
|
||||
if (this._countdownTimer >= 1) {
|
||||
this._countdownTimer -= 1;
|
||||
this._countdown--;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_startTeamGame(data) {
|
||||
const sm = GameGlobal.sceneManager;
|
||||
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
|
||||
const TeamGameScene = require('./TeamGameScene');
|
||||
sm.register(SCENE.TEAM_GAME, TeamGameScene);
|
||||
}
|
||||
sm.switchTo(SCENE.TEAM_GAME, {
|
||||
teamId: this._teamData ? this._teamData.teamId : null,
|
||||
mapId: data.mapId,
|
||||
teamA: data.teamA,
|
||||
teamB: data.teamB,
|
||||
teamABaseHp: data.teamABaseHp,
|
||||
teamBBaseHp: data.teamBBaseHp,
|
||||
myPlayerId: this._myPlayerId,
|
||||
});
|
||||
},
|
||||
|
||||
render(ctx) {
|
||||
// Background
|
||||
ctx.fillStyle = COLORS.MENU_BG;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
// Top accent bar
|
||||
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
|
||||
gradient.addColorStop(0, '#0f3460');
|
||||
gradient.addColorStop(0.5, '#e94560');
|
||||
gradient.addColorStop(1, '#0f3460');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
|
||||
|
||||
// Back button
|
||||
this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12);
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 22px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('teamRoom.title'), CENTER_X, SCREEN_HEIGHT * 0.08);
|
||||
|
||||
switch (this._state) {
|
||||
case TEAM_STATE.MODE_SELECT:
|
||||
this._renderModeSelect(ctx);
|
||||
break;
|
||||
case TEAM_STATE.JOINING:
|
||||
this._renderJoining(ctx);
|
||||
break;
|
||||
case TEAM_STATE.FORMING:
|
||||
this._renderForming(ctx);
|
||||
break;
|
||||
case TEAM_STATE.MATCHING:
|
||||
this._renderMatching(ctx);
|
||||
break;
|
||||
case TEAM_STATE.COUNTDOWN:
|
||||
this._renderCountdown(ctx);
|
||||
break;
|
||||
case TEAM_STATE.ERROR:
|
||||
this._renderError(ctx);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_renderJoining(ctx) {
|
||||
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '18px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('teamRoom.joining') + dots, CENTER_X, SCREEN_HEIGHT * 0.45);
|
||||
},
|
||||
|
||||
_renderModeSelect(ctx) {
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('teamRoom.chooseMode'), CENTER_X, SCREEN_HEIGHT * 0.28);
|
||||
|
||||
this._drawButton(ctx, this._createTeamBtnRect, t('teamRoom.createTeam'));
|
||||
this._drawButton(ctx, this._soloMatchBtnRect, t('teamRoom.soloMatch'));
|
||||
},
|
||||
|
||||
_renderForming(ctx) {
|
||||
if (!this._teamData) return;
|
||||
|
||||
// Team ID display
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('teamRoom.teamId', { id: this._teamData.teamId }), CENTER_X, SCREEN_HEIGHT * 0.16);
|
||||
|
||||
// Render team member slots
|
||||
const members = this._teamData.teamA || [];
|
||||
for (let i = 0; i < TEAM_SIZE; i++) {
|
||||
const rect = this._slotRects[i];
|
||||
const member = members[i];
|
||||
|
||||
// Slot background
|
||||
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
|
||||
ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
|
||||
ctx.lineWidth = member && member.isLeader ? 3 : 1;
|
||||
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
if (member) {
|
||||
// Avatar placeholder (circle)
|
||||
const avatarR = Math.min(rect.w, rect.h) * 0.22;
|
||||
const avatarCX = rect.x + rect.w / 2;
|
||||
const avatarCY = rect.y + rect.h * 0.3;
|
||||
|
||||
ctx.fillStyle = member.isLeader ? '#FFD700' : '#4a90d9';
|
||||
ctx.beginPath();
|
||||
ctx.arc(avatarCX, avatarCY, avatarR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Player icon
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = `${avatarR}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('🎖', avatarCX, avatarCY);
|
||||
|
||||
// Leader badge
|
||||
if (member.isLeader) {
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 9px Arial';
|
||||
ctx.fillText(t('teamRoom.leader'), avatarCX, avatarCY + avatarR + 10);
|
||||
}
|
||||
|
||||
// Player name (truncated)
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '10px Arial';
|
||||
const name = member.playerId.length > 8 ? member.playerId.substring(0, 8) + '..' : member.playerId;
|
||||
ctx.fillText(name, avatarCX, rect.y + rect.h * 0.7);
|
||||
|
||||
// Ready state
|
||||
if (!member.isLeader) {
|
||||
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
|
||||
ctx.font = 'bold 10px Arial';
|
||||
ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88);
|
||||
}
|
||||
|
||||
// Kick button (only for leader, not on self)
|
||||
if (this._isLeader && !member.isLeader && member.playerId !== this._myPlayerId) {
|
||||
const kickRect = this._kickBtnRects[i];
|
||||
ctx.fillStyle = '#FF4444';
|
||||
ctx.font = 'bold 12px Arial';
|
||||
ctx.fillText('✕', kickRect.x + kickRect.w / 2, kickRect.y + kickRect.h / 2);
|
||||
}
|
||||
} else {
|
||||
// Empty slot
|
||||
ctx.fillStyle = '#555555';
|
||||
ctx.font = '24px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
|
||||
ctx.fillStyle = '#666666';
|
||||
ctx.font = '10px Arial';
|
||||
ctx.fillText(t('teamRoom.emptySlot'), rect.x + rect.w / 2, rect.y + rect.h * 0.78);
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons based on role
|
||||
if (this._isLeader) {
|
||||
this._drawButton(ctx, this._inviteBtnRect, t('teamRoom.invite'));
|
||||
|
||||
// Match button: only enabled if all ready
|
||||
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
|
||||
this._drawButton(ctx, this._matchBtnRect, t('teamRoom.startMatch'), false, 14, allReady ? null : '#555555');
|
||||
this._drawButton(ctx, this._disbandBtnRect, t('teamRoom.disband'), false, 12, '#8B0000');
|
||||
} else {
|
||||
// Member: ready/unready button
|
||||
const myMember = members.find(m => m.playerId === this._myPlayerId);
|
||||
const readyLabel = myMember && myMember.ready ? t('teamRoom.cancelReady') : t('teamRoom.readyBtn');
|
||||
this._drawButton(ctx, this._readyBtnRect, readyLabel);
|
||||
this._drawButton(ctx, this._leaveBtnRect, t('teamRoom.leaveTeam'), false, 12, '#8B0000');
|
||||
}
|
||||
},
|
||||
|
||||
_renderMatching(ctx) {
|
||||
if (!this._teamData) return;
|
||||
|
||||
// Render team slots (smaller, at top)
|
||||
const members = this._teamData.teamA || [];
|
||||
for (let i = 0; i < TEAM_SIZE; i++) {
|
||||
const rect = this._slotRects[i];
|
||||
const member = members[i];
|
||||
|
||||
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
|
||||
ctx.strokeStyle = '#0f3460';
|
||||
ctx.lineWidth = 1;
|
||||
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
if (member) {
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '10px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const name = member.playerId.length > 8 ? member.playerId.substring(0, 8) + '..' : member.playerId;
|
||||
ctx.fillText(name, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Matching animation
|
||||
const elapsed = Math.floor(this._matchTimer);
|
||||
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '18px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('teamRoom.matching', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
|
||||
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillText(t('teamRoom.waitTime', { seconds: elapsed }), CENTER_X, SCREEN_HEIGHT * 0.62);
|
||||
|
||||
// Cancel button (leader only)
|
||||
if (this._isLeader) {
|
||||
this._drawButton(ctx, this._cancelMatchBtnRect, t('teamRoom.cancelMatch'));
|
||||
}
|
||||
},
|
||||
|
||||
_renderCountdown(ctx) {
|
||||
ctx.fillStyle = '#00FF00';
|
||||
ctx.font = '16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t('teamRoom.matchFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
|
||||
|
||||
ctx.fillStyle = COLORS.MENU_TITLE;
|
||||
ctx.font = 'bold 64px Arial';
|
||||
ctx.fillText(String(Math.max(1, this._countdown)), CENTER_X, SCREEN_HEIGHT * 0.52);
|
||||
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillText(t('teamRoom.enterBattle'), CENTER_X, SCREEN_HEIGHT * 0.65);
|
||||
},
|
||||
|
||||
_renderError(ctx) {
|
||||
ctx.fillStyle = '#FF4444';
|
||||
ctx.font = '16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(this._errorMsg, CENTER_X, SCREEN_HEIGHT * 0.45);
|
||||
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillText(t('teamRoom.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
|
||||
},
|
||||
|
||||
_drawButton(ctx, rect, label, pressed, fontSize, bgColor) {
|
||||
if (!rect) return;
|
||||
const fs = fontSize || 14;
|
||||
|
||||
ctx.fillStyle = bgColor || (pressed ? '#0f3460' : COLORS.MENU_BTN);
|
||||
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 6);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = pressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
|
||||
ctx.font = `bold ${fs}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
},
|
||||
|
||||
_drawRoundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.arcTo(x, y, x + r, y, r);
|
||||
ctx.closePath();
|
||||
},
|
||||
|
||||
_hitTest(tx, ty, rect) {
|
||||
if (!rect) return false;
|
||||
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
|
||||
},
|
||||
|
||||
handleTouch(eventType, e) {
|
||||
if (eventType !== 'touchstart') return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
|
||||
// Back button
|
||||
if (this._hitTest(tx, ty, this._backBtnRect)) {
|
||||
this._goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this._state) {
|
||||
case TEAM_STATE.MODE_SELECT:
|
||||
if (this._hitTest(tx, ty, this._createTeamBtnRect)) {
|
||||
this._handleCreateTeam();
|
||||
} else if (this._hitTest(tx, ty, this._soloMatchBtnRect)) {
|
||||
this._handleSoloMatch();
|
||||
}
|
||||
break;
|
||||
|
||||
case TEAM_STATE.FORMING:
|
||||
this._handleFormingTouch(tx, ty);
|
||||
break;
|
||||
|
||||
case TEAM_STATE.MATCHING:
|
||||
if (this._isLeader && this._hitTest(tx, ty, this._cancelMatchBtnRect)) {
|
||||
this._handleCancelMatch();
|
||||
}
|
||||
break;
|
||||
|
||||
case TEAM_STATE.ERROR:
|
||||
this._state = TEAM_STATE.MODE_SELECT;
|
||||
this._errorMsg = '';
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_handleFormingTouch(tx, ty) {
|
||||
if (!this._teamData) return;
|
||||
|
||||
if (this._isLeader) {
|
||||
// Invite button
|
||||
if (this._hitTest(tx, ty, this._inviteBtnRect)) {
|
||||
this._handleInvite();
|
||||
return;
|
||||
}
|
||||
|
||||
// Match button
|
||||
if (this._hitTest(tx, ty, this._matchBtnRect)) {
|
||||
this._handleStartMatch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Disband button
|
||||
if (this._hitTest(tx, ty, this._disbandBtnRect)) {
|
||||
this._handleDisband();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kick buttons
|
||||
const members = this._teamData.teamA || [];
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
const member = members[i];
|
||||
if (member && !member.isLeader && member.playerId !== this._myPlayerId) {
|
||||
if (this._hitTest(tx, ty, this._kickBtnRects[i])) {
|
||||
this._handleKick(member.playerId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ready button
|
||||
if (this._hitTest(tx, ty, this._readyBtnRect)) {
|
||||
this._handleReady();
|
||||
return;
|
||||
}
|
||||
|
||||
// Leave button
|
||||
if (this._hitTest(tx, ty, this._leaveBtnRect)) {
|
||||
this._handleLeave();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async _handleCreateTeam() {
|
||||
const nm = this._networkManager;
|
||||
if (!nm) return;
|
||||
|
||||
if (!nm.connected) {
|
||||
const ok = await nm.connect(this._serverUrl);
|
||||
if (!ok) {
|
||||
this._errorMsg = t('common.cannotConnect');
|
||||
this._state = TEAM_STATE.ERROR;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._myPlayerId = nm.playerId;
|
||||
nm.send(NET_MSG.CREATE_TEAM, {});
|
||||
},
|
||||
|
||||
async _handleSoloMatch() {
|
||||
const nm = this._networkManager;
|
||||
if (!nm) return;
|
||||
|
||||
if (!nm.connected) {
|
||||
const ok = await nm.connect(this._serverUrl);
|
||||
if (!ok) {
|
||||
this._errorMsg = t('common.cannotConnect');
|
||||
this._state = TEAM_STATE.ERROR;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._myPlayerId = nm.playerId;
|
||||
this._matchTimer = 0;
|
||||
// State will be updated by TEAM_STATE event from server
|
||||
nm.send(NET_MSG.SOLO_MATCH, {});
|
||||
},
|
||||
|
||||
async _autoJoinTeam(teamId) {
|
||||
const nm = this._networkManager;
|
||||
if (!nm) {
|
||||
this._errorMsg = t('common.cannotConnect');
|
||||
this._state = TEAM_STATE.ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show a joining indicator
|
||||
this._state = TEAM_STATE.JOINING;
|
||||
this._errorMsg = '';
|
||||
|
||||
try {
|
||||
if (!nm.connected) {
|
||||
const ok = await nm.connect(this._serverUrl);
|
||||
if (!ok) {
|
||||
this._errorMsg = t('common.cannotConnect');
|
||||
this._state = TEAM_STATE.ERROR;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._myPlayerId = nm.playerId;
|
||||
console.log(`[TeamRoom] Auto-joining team ${teamId} as ${this._myPlayerId}`);
|
||||
nm.send(NET_MSG.JOIN_TEAM, { teamId });
|
||||
} catch (e) {
|
||||
console.error('[TeamRoom] Auto-join failed:', e);
|
||||
this._errorMsg = t('common.cannotConnect');
|
||||
this._state = TEAM_STATE.ERROR;
|
||||
}
|
||||
},
|
||||
|
||||
_handleInvite() {
|
||||
if (!this._teamData) return;
|
||||
|
||||
const teamId = this._teamData.teamId;
|
||||
const shareData = {
|
||||
title: t('teamRoom.shareTitle'),
|
||||
imageUrl: '',
|
||||
query: `teamId=${teamId}`,
|
||||
};
|
||||
|
||||
console.log(`[TeamRoom] Sharing invite with query: teamId=${teamId}`);
|
||||
|
||||
// WeChat mini-game policy: direct wx.shareAppMessage() calls are forbidden.
|
||||
// Must use passive sharing via onShareAppMessage callback.
|
||||
const shareManager = GameGlobal.shareManager;
|
||||
if (shareManager) {
|
||||
shareManager.triggerShare(shareData);
|
||||
} else {
|
||||
try {
|
||||
wx.showToast({
|
||||
title: '请点击右上角 ··· 转发给好友',
|
||||
icon: 'none',
|
||||
duration: 2500,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('[TeamRoom] Share not available, teamId:', teamId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_handleStartMatch() {
|
||||
const nm = this._networkManager;
|
||||
if (!nm || !this._teamData) return;
|
||||
|
||||
// Check all members are ready before sending
|
||||
const members = this._teamData.teamA || [];
|
||||
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
|
||||
if (!allReady) return;
|
||||
|
||||
this._matchTimer = 0;
|
||||
nm.send(NET_MSG.MATCH_START, {});
|
||||
},
|
||||
|
||||
_handleCancelMatch() {
|
||||
const nm = this._networkManager;
|
||||
if (!nm) return;
|
||||
nm.send(NET_MSG.MATCH_CANCEL, {});
|
||||
},
|
||||
|
||||
_handleReady() {
|
||||
const nm = this._networkManager;
|
||||
if (!nm || !this._teamData) return;
|
||||
|
||||
const myMember = (this._teamData.teamA || []).find(m => m.playerId === this._myPlayerId);
|
||||
nm.send(NET_MSG.TEAM_READY, { ready: myMember ? !myMember.ready : true });
|
||||
},
|
||||
|
||||
_handleKick(playerId) {
|
||||
const nm = this._networkManager;
|
||||
if (!nm) return;
|
||||
nm.send(NET_MSG.TEAM_KICK, { playerId });
|
||||
},
|
||||
|
||||
_handleDisband() {
|
||||
const nm = this._networkManager;
|
||||
if (!nm) return;
|
||||
nm.send(NET_MSG.TEAM_DISBAND, {});
|
||||
},
|
||||
|
||||
_handleLeave() {
|
||||
const nm = this._networkManager;
|
||||
if (!nm) return;
|
||||
nm.send(NET_MSG.LEAVE_TEAM, {});
|
||||
this._teamData = null;
|
||||
this._state = TEAM_STATE.MODE_SELECT;
|
||||
},
|
||||
|
||||
_goBack() {
|
||||
// Leave team if in one
|
||||
if (this._teamData) {
|
||||
const nm = this._networkManager;
|
||||
if (nm) {
|
||||
if (this._state === TEAM_STATE.MATCHING && this._isLeader) {
|
||||
// Cancel match first, then disband
|
||||
nm.send(NET_MSG.MATCH_CANCEL, {});
|
||||
}
|
||||
if (this._isLeader) {
|
||||
nm.send(NET_MSG.TEAM_DISBAND, {});
|
||||
} else {
|
||||
nm.send(NET_MSG.LEAVE_TEAM, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sm = GameGlobal.sceneManager;
|
||||
sm.switchTo(SCENE.MENU);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = TeamRoomScene;
|
||||
Reference in New Issue
Block a user