import { ClientMessage, ServerMessage } from './protocol.js'; import { returnToLobbyContainer, leaveRoomAndReturn } from './lobby.js'; import { playShot, playWallHit, playExplosion, playWin, playLose } from './audio.js'; import { getLang } from '../i18n.js'; const STR = { ru: { controlsHint: 'WASD / стрелки + пробел тоже работают', fire: 'огонь', titles: { win: 'ПОБЕДА', lose: 'ПОРАЖЕНИЕ', draw: 'НИЧЬЯ' }, rematch: 'играть ещё', toLobby: 'в лобби', }, en: { controlsHint: 'WASD / arrow keys + space also work', fire: 'fire', titles: { win: 'VICTORY', lose: 'DEFEAT', draw: 'DRAW' }, rematch: 'play again', toLobby: 'to lobby', }, }; function t() { return STR[getLang()]; } // Регистрируем один раз на модуль: обновляем статичные подписи в уже // отрисованной панели управления и оверлей победы/поражения, если он открыт. window.addEventListener('cacto:langchange', () => { const hint = document.querySelector('.battle-controls__hint'); if (hint) hint.textContent = t().controlsHint; const fireBtn = document.querySelector('.fire-btn'); if (fireBtn) fireBtn.textContent = t().fire; if (lastOverPayload) renderOverlay(lastOverPayload); }); const TILE_PX = 32; // Quintus транслирует и вращает контекст сам (Sprite.render -> matrix.setContextTransform) // на основе p.x/p.y/p.angle — draw() получает уже трансформированный контекст // и должен рисовать спрайт центрированным в (0,0), без своих translate/rotate. const DIR_DEGREES = { up: 0, right: 90, down: 180, left: 270 }; let Q = null; let net = null; let myPlayerId = null; let stage = null; let tankSprites = new Map(); let bulletSprites = new Map(); let wallSprites = new Map(); // "x,y" (тайл) -> спрайт, для синка разрушенных стен let baseSprite = null; let lastSentInput = null; let seq = 0; let polling = false; function tankColor(p) { if (p.isAi) return '#0a8f2c'; return p.isMe ? '#00ff41' : '#9fa3a0'; } function setupEntities() { Q.Sprite.extend('Tank', { init(p) { this._super(p, { w: 24, h: 24, renderAlways: true }); }, draw(ctx) { const p = this.p; const color = tankColor(p); // корпус ctx.fillStyle = color; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); // гусеницы по бокам ctx.fillStyle = '#0a0e0c'; ctx.fillRect(-p.w / 2, -p.h / 2, 3, p.h); ctx.fillRect(p.w / 2 - 3, -p.h / 2, 3, p.h); // башня ctx.beginPath(); ctx.arc(0, 0, p.w / 2 - 5, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.lineWidth = 1.5; ctx.strokeStyle = '#0a0e0c'; ctx.stroke(); // ствол — явно торчит вперёд по направлению взгляда (локально «вверх»). // Ярко-зелёный с тёмной обводкой — иначе на тёмном корпусе не виден. ctx.fillStyle = '#00ff41'; ctx.strokeStyle = '#0a0e0c'; ctx.lineWidth = 1; ctx.fillRect(-2.5, -p.h / 2 - 12, 5, 16); ctx.strokeRect(-2.5, -p.h / 2 - 12, 5, 16); }, }); Q.Sprite.extend('Bullet', { init(p) { this._super(p, { w: 8, h: 8, renderAlways: true }); }, draw(ctx) { ctx.fillStyle = '#c7c7c7'; ctx.beginPath(); ctx.arc(0, 0, this.p.w / 2, 0, Math.PI * 2); ctx.fill(); }, }); Q.Sprite.extend('Wall', { init(p) { this._super(p, { w: TILE_PX, h: TILE_PX }); }, draw(ctx) { const p = this.p; if (p.tile === 2) { ctx.fillStyle = '#9fa3a0'; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); } else { ctx.fillStyle = '#0a8f2c'; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); ctx.strokeStyle = '#0a0e0c'; ctx.strokeRect(-p.w / 2 + 1, -p.h / 2 + 1, p.w - 2, p.h - 2); } }, }); Q.Sprite.extend('Base', { init(p) { this._super(p, { w: TILE_PX * 0.85, h: TILE_PX * 0.85, renderAlways: true }); }, draw(ctx) { const p = this.p; ctx.fillStyle = p.alive ? '#9fa3a0' : '#2a2d2b'; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); ctx.strokeStyle = '#0a0e0c'; ctx.strokeRect(-p.w / 2 + 1, -p.h / 2 + 1, p.w - 2, p.h - 2); ctx.fillStyle = p.alive ? '#00ff41' : '#0a0e0c'; ctx.beginPath(); ctx.moveTo(0, -p.h / 2); ctx.lineTo(-p.w / 4, 0); ctx.lineTo(p.w / 4, 0); ctx.closePath(); ctx.fill(); }, }); } function ensureQuintus(cols, rows) { if (Q) { return; } Q = window.Quintus().include('Sprites, Scenes, 2D, Input').setup({ width: cols * TILE_PX, height: rows * TILE_PX, autoFocus: true, }); Q.el.tabIndex = 1; Q.input.keyboardControls({ LEFT: 'left', RIGHT: 'right', UP: 'up', DOWN: 'down', A: 'left', D: 'right', W: 'up', S: 'down', SPACE: 'fire', }); setupEntities(); Q.scene('battle', (stageRef) => { stage = stageRef; }); } function buildMap(map) { for (let y = 0; y < map.length; y++) { for (let x = 0; x < map[y].length; x++) { const tile = map[y][x]; if (tile === 0) { continue; } const sprite = new Q.Wall({ x: x * TILE_PX + TILE_PX / 2, y: y * TILE_PX + TILE_PX / 2, tile, }); stage.insert(sprite); if (tile === 1) { // только кирпич может быть разрушен — только его отслеживаем wallSprites.set(`${x},${y}`, sprite); } } } } function bindControlButtons(root) { const press = (input, active) => { if (Q) { Q.inputs[input] = active; } }; root.querySelectorAll('[data-input]').forEach((btn) => { const input = btn.dataset.input; // Клик по кнопке уводит фокус с канваса — а Quintus слушает клавиатуру // именно на нём, так что после клика WASD/стрелки перестанут работать. btn.addEventListener('mousedown', () => { press(input, true); Q?.el.focus(); }); btn.addEventListener('mouseup', () => press(input, false)); btn.addEventListener('mouseleave', () => press(input, false)); btn.addEventListener('touchstart', (e) => { e.preventDefault(); press(input, true); }); btn.addEventListener('touchend', (e) => { e.preventDefault(); press(input, false); }); }); } function pollInput() { if (!polling) { return; } const input = { seq: seq++, up: !!Q.inputs.up, down: !!Q.inputs.down, left: !!Q.inputs.left, right: !!Q.inputs.right, fire: !!Q.inputs.fire, }; const key = `${input.up}${input.down}${input.left}${input.right}${input.fire}`; if (key !== lastSentInput) { lastSentInput = key; net.send(ClientMessage.GAME_INPUT, input); } requestAnimationFrame(pollInput); } function onState(payload) { const seenTanks = new Set(); for (const t of payload.tanks) { seenTanks.add(t.id); let sprite = tankSprites.get(t.id); if (!sprite) { sprite = new Q.Tank({ x: t.x * TILE_PX, y: t.y * TILE_PX, isMe: t.id === myPlayerId, isAi: !!t.is_ai, }); stage.insert(sprite); tankSprites.set(t.id, sprite); } else if (!sprite.p.hidden && !t.alive) { playExplosion(); } sprite.p.x = t.x * TILE_PX; sprite.p.y = t.y * TILE_PX; sprite.p.angle = DIR_DEGREES[t.direction] || 0; sprite.p.hidden = !t.alive; } for (const [id, sprite] of tankSprites) { if (!seenTanks.has(id)) { sprite.destroy(); tankSprites.delete(id); } } const seenBullets = new Set(); for (const b of payload.bullets) { seenBullets.add(b.id); let sprite = bulletSprites.get(b.id); if (!sprite) { sprite = new Q.Bullet({ x: b.x * TILE_PX, y: b.y * TILE_PX }); stage.insert(sprite); bulletSprites.set(b.id, sprite); playShot(); } sprite.p.x = b.x * TILE_PX; sprite.p.y = b.y * TILE_PX; } for (const [id, sprite] of bulletSprites) { if (!seenBullets.has(id)) { sprite.destroy(); bulletSprites.delete(id); } } if (payload.destroyed_tiles) { for (const [tx, ty] of payload.destroyed_tiles) { const key = `${tx},${ty}`; const sprite = wallSprites.get(key); if (sprite) { sprite.destroy(); wallSprites.delete(key); } } if (payload.destroyed_tiles.length > 0) { playWallHit(); } } if (payload.base) { if (!baseSprite) { baseSprite = new Q.Base({ x: payload.base.x * TILE_PX, y: payload.base.y * TILE_PX, alive: payload.base.alive, }); stage.insert(baseSprite); } else { if (baseSprite.p.alive && !payload.base.alive) { playExplosion(); } baseSprite.p.alive = payload.base.alive; } } } let lastOverPayload = null; function renderOverlay(payload) { const title = t().titles[payload.result] || payload.result.toUpperCase(); const overlay = document.getElementById('battle-result-overlay'); overlay.innerHTML = `

${title}

${payload.reason}

`; overlay.style.display = 'flex'; } function onOver(payload) { polling = false; if (payload.result === 'win') { playWin(); } else { playLose(); } lastOverPayload = payload; renderOverlay(payload); } function onOverlayClick(e) { const btn = e.target.closest('[data-action]'); if (!btn) return; const overlay = document.getElementById('battle-result-overlay'); overlay.style.display = 'none'; overlay.innerHTML = ''; lastOverPayload = null; if (btn.dataset.action === 'rematch') { returnToLobbyContainer(); } else { leaveRoomAndReturn(); } } export function startBattle(networkInstance, payload, ownPlayerId) { net = networkInstance; myPlayerId = ownPlayerId; const rows = payload.map.length; const cols = payload.map[0].length; document.getElementById('lobby').style.display = 'none'; const container = document.getElementById('battlecity-canvas-container'); container.style.display = 'block'; container.innerHTML = `

${t().controlsHint}

`; ensureQuintus(cols, rows); document.getElementById('battle-canvas-slot').appendChild(Q.el); // Клавиатурные события Quintus слушает на самом канвасе — если фокус // не на нём (а он теряется при каждом переприкреплении к DOM на реванш), // WASD/стрелки/space просто никуда не долетают. Q.el.focus(); bindControlButtons(container.querySelector('.battle-controls')); container.querySelector('#battle-result-overlay').addEventListener('click', onOverlayClick); Q.stageScene('battle', 0); tankSprites = new Map(); bulletSprites = new Map(); wallSprites = new Map(); baseSprite = null; buildMap(payload.map); net.on(ServerMessage.GAME_STATE, onState); net.on(ServerMessage.GAME_OVER, onOver); lastSentInput = null; seq = 0; polling = true; requestAnimationFrame(pollInput); }