|
|
|
@ -34,7 +34,7 @@
|
|
|
|
hud.style.cssText =
|
|
|
|
hud.style.cssText =
|
|
|
|
"width:800px;max-width:100%;margin:8px auto;color:#e8eadf;font:16px/1.4 system-ui,sans-serif;display:flex;gap:18px;flex-wrap:wrap;";
|
|
|
|
"width:800px;max-width:100%;margin:8px auto;color:#e8eadf;font:16px/1.4 system-ui,sans-serif;display:flex;gap:18px;flex-wrap:wrap;";
|
|
|
|
hud.innerHTML =
|
|
|
|
hud.innerHTML =
|
|
|
|
'Score: <span id="score">0</span> Lives: <span id="lives">3</span> Enemies: <span id="enemies">0</span>';
|
|
|
|
'Score: <span id="score">0</span> Health: <span id="lives">100%</span> Enemies: <span id="enemies">0</span>';
|
|
|
|
canvas.parentNode.insertBefore(hud, canvas.nextSibling);
|
|
|
|
canvas.parentNode.insertBefore(hud, canvas.nextSibling);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -68,6 +68,9 @@
|
|
|
|
const pauseAction = document.querySelector("#pauseAction");
|
|
|
|
const pauseAction = document.querySelector("#pauseAction");
|
|
|
|
const stopAction = document.querySelector("#stopAction");
|
|
|
|
const stopAction = document.querySelector("#stopAction");
|
|
|
|
const endAction = document.querySelector("#endAction");
|
|
|
|
const endAction = document.querySelector("#endAction");
|
|
|
|
|
|
|
|
const fireAction = document.querySelector("#fireAction");
|
|
|
|
|
|
|
|
const moveStick = document.querySelector("#moveStick");
|
|
|
|
|
|
|
|
const moveKnob = document.querySelector("#moveKnob");
|
|
|
|
|
|
|
|
|
|
|
|
const WIDTH = canvas.width;
|
|
|
|
const WIDTH = canvas.width;
|
|
|
|
const HEIGHT = canvas.height;
|
|
|
|
const HEIGHT = canvas.height;
|
|
|
|
@ -80,6 +83,8 @@
|
|
|
|
const FIRE_COOLDOWN = 0.34;
|
|
|
|
const FIRE_COOLDOWN = 0.34;
|
|
|
|
const ENEMY_FIRE_COOLDOWN = 1.35;
|
|
|
|
const ENEMY_FIRE_COOLDOWN = 1.35;
|
|
|
|
const BRICK_SIZE = 20;
|
|
|
|
const BRICK_SIZE = 20;
|
|
|
|
|
|
|
|
const PLAYER_MAX_HEALTH = 100;
|
|
|
|
|
|
|
|
const PLAYER_HIT_DAMAGE = 34;
|
|
|
|
const DIRS = {
|
|
|
|
const DIRS = {
|
|
|
|
up: { x: 0, y: -1 },
|
|
|
|
up: { x: 0, y: -1 },
|
|
|
|
down: { x: 0, y: 1 },
|
|
|
|
down: { x: 0, y: 1 },
|
|
|
|
@ -90,27 +95,92 @@
|
|
|
|
const keys = new Set();
|
|
|
|
const keys = new Set();
|
|
|
|
let state = "menu";
|
|
|
|
let state = "menu";
|
|
|
|
let score = 0;
|
|
|
|
let score = 0;
|
|
|
|
let lives = 3;
|
|
|
|
let health = PLAYER_MAX_HEALTH;
|
|
|
|
let player;
|
|
|
|
let player;
|
|
|
|
let enemies = [];
|
|
|
|
let enemies = [];
|
|
|
|
let bullets = [];
|
|
|
|
let bullets = [];
|
|
|
|
let particles = [];
|
|
|
|
let particles = [];
|
|
|
|
let lastTime = 0;
|
|
|
|
let lastTime = 0;
|
|
|
|
|
|
|
|
|
|
|
|
const walls = [
|
|
|
|
// Static brick layout (kept stable across games). Steel walls are randomized
|
|
|
|
...brickWall(120, 80, TILE, TILE * 3),
|
|
|
|
// on every reset, so they are added separately in resetGame via regenerateWalls.
|
|
|
|
...brickWall(240, 120, TILE * 4, TILE),
|
|
|
|
const BRICK_LAYOUT = [
|
|
|
|
...steelWall(520, 80, TILE, TILE * 3),
|
|
|
|
[120, 80, TILE, TILE * 3],
|
|
|
|
...brickWall(640, 160, TILE * 2, TILE),
|
|
|
|
[240, 120, TILE * 4, TILE],
|
|
|
|
...brickWall(80, 320, TILE * 3, TILE),
|
|
|
|
[640, 160, TILE * 2, TILE],
|
|
|
|
...brickWall(280, 280, TILE, TILE * 3),
|
|
|
|
[80, 320, TILE * 3, TILE],
|
|
|
|
...brickWall(400, 360, TILE * 4, TILE),
|
|
|
|
[280, 280, TILE, TILE * 3],
|
|
|
|
...steelWall(640, 360, TILE, TILE * 3),
|
|
|
|
[400, 360, TILE * 4, TILE],
|
|
|
|
...steelWall(560, 240, TILE * 2, TILE),
|
|
|
|
[160, 480, TILE * 2, TILE],
|
|
|
|
...brickWall(160, 480, TILE * 2, TILE),
|
|
|
|
[480, 500, TILE * 3, TILE],
|
|
|
|
...brickWall(480, 500, TILE * 3, TILE),
|
|
|
|
|
|
|
|
];
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Rectangles that steel walls must avoid so spawns are not blocked or boxed in.
|
|
|
|
|
|
|
|
// Each entry is padded around a player/enemy starting position.
|
|
|
|
|
|
|
|
const SPAWN_KEEPOUTS = [
|
|
|
|
|
|
|
|
spawnKeepout(384, 536), // player
|
|
|
|
|
|
|
|
spawnKeepout(84, 64),
|
|
|
|
|
|
|
|
spawnKeepout(384, 64),
|
|
|
|
|
|
|
|
spawnKeepout(684, 64),
|
|
|
|
|
|
|
|
spawnKeepout(84, 220),
|
|
|
|
|
|
|
|
spawnKeepout(684, 260),
|
|
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let walls = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function spawnKeepout(x, y) {
|
|
|
|
|
|
|
|
// Generous margin (1.5 tiles) around the spawn keeps lanes open so tanks
|
|
|
|
|
|
|
|
// are never immediately boxed in by randomized steel.
|
|
|
|
|
|
|
|
const margin = TILE * 1.5;
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
x: x - margin,
|
|
|
|
|
|
|
|
y: y - margin,
|
|
|
|
|
|
|
|
w: TANK_SIZE + margin * 2,
|
|
|
|
|
|
|
|
h: TANK_SIZE + margin * 2,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function buildBrickWalls() {
|
|
|
|
|
|
|
|
const pieces = [];
|
|
|
|
|
|
|
|
for (const [x, y, w, h] of BRICK_LAYOUT) {
|
|
|
|
|
|
|
|
pieces.push(...brickWall(x, y, w, h));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return pieces;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function randomSteelWalls(occupied) {
|
|
|
|
|
|
|
|
// Generate a handful of vertical/horizontal steel segments at random tile
|
|
|
|
|
|
|
|
// positions, skipping any that overlap a spawn keep-out zone.
|
|
|
|
|
|
|
|
const pieces = [];
|
|
|
|
|
|
|
|
const segmentCount = 5 + Math.floor(Math.random() * 3); // 5-7 steel wall segments
|
|
|
|
|
|
|
|
const cols = Math.floor(WIDTH / TILE);
|
|
|
|
|
|
|
|
const rows = Math.floor(HEIGHT / TILE);
|
|
|
|
|
|
|
|
let placedSegments = 0;
|
|
|
|
|
|
|
|
let attempts = 0;
|
|
|
|
|
|
|
|
while (placedSegments < segmentCount && attempts < segmentCount * 30) {
|
|
|
|
|
|
|
|
attempts += 1;
|
|
|
|
|
|
|
|
const vertical = Math.random() < 0.5;
|
|
|
|
|
|
|
|
const length = (2 + Math.floor(Math.random() * 3)) * TILE; // 2-4 tiles long
|
|
|
|
|
|
|
|
const x = Math.floor(Math.random() * cols) * TILE;
|
|
|
|
|
|
|
|
const y = Math.floor(Math.random() * rows) * TILE;
|
|
|
|
|
|
|
|
const w = vertical ? TILE : length;
|
|
|
|
|
|
|
|
const h = vertical ? length : TILE;
|
|
|
|
|
|
|
|
const candidate = { x, y, w, h };
|
|
|
|
|
|
|
|
if (x + w > WIDTH || y + h > HEIGHT) continue;
|
|
|
|
|
|
|
|
if (SPAWN_KEEPOUTS.some((zone) => intersects(candidate, zone))) continue;
|
|
|
|
|
|
|
|
if (occupied.some((area) => intersects(candidate, area))) continue;
|
|
|
|
|
|
|
|
pieces.push(...steelWall(x, y, w, h));
|
|
|
|
|
|
|
|
occupied.push(candidate);
|
|
|
|
|
|
|
|
placedSegments += 1;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return pieces;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function regenerateWalls() {
|
|
|
|
|
|
|
|
const bricks = buildBrickWalls();
|
|
|
|
|
|
|
|
walls = [...bricks, ...randomSteelWalls([...bricks])];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rect(x, y, w, h, type = "brick") {
|
|
|
|
function rect(x, y, w, h, type = "brick") {
|
|
|
|
return { x, y, w, h, type, destructible: type === "brick" };
|
|
|
|
return { x, y, w, h, type, destructible: type === "brick" };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -144,15 +214,17 @@
|
|
|
|
team,
|
|
|
|
team,
|
|
|
|
fireTimer: 0,
|
|
|
|
fireTimer: 0,
|
|
|
|
aiTimer: 0.4 + Math.random() * 1.2,
|
|
|
|
aiTimer: 0.4 + Math.random() * 1.2,
|
|
|
|
|
|
|
|
avoidTimer: 0,
|
|
|
|
alive: true,
|
|
|
|
alive: true,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetGame() {
|
|
|
|
function resetGame() {
|
|
|
|
score = 0;
|
|
|
|
score = 0;
|
|
|
|
lives = 3;
|
|
|
|
health = PLAYER_MAX_HEALTH;
|
|
|
|
bullets = [];
|
|
|
|
bullets = [];
|
|
|
|
particles = [];
|
|
|
|
particles = [];
|
|
|
|
|
|
|
|
regenerateWalls();
|
|
|
|
player = tank(384, 536, "#77b255", "up", "player");
|
|
|
|
player = tank(384, 536, "#77b255", "up", "player");
|
|
|
|
enemies = [
|
|
|
|
enemies = [
|
|
|
|
tank(84, 64, "#db5a42", "down"),
|
|
|
|
tank(84, 64, "#db5a42", "down"),
|
|
|
|
@ -214,7 +286,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
function updateHud() {
|
|
|
|
function updateHud() {
|
|
|
|
scoreEl.textContent = score;
|
|
|
|
scoreEl.textContent = score;
|
|
|
|
livesEl.textContent = lives;
|
|
|
|
livesEl.textContent = `${health}%`;
|
|
|
|
enemiesEl.textContent = enemies.filter((enemy) => enemy.alive).length;
|
|
|
|
enemiesEl.textContent = enemies.filter((enemy) => enemy.alive).length;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -277,9 +349,10 @@
|
|
|
|
const livingEnemies = enemies.filter((enemy) => enemy.alive);
|
|
|
|
const livingEnemies = enemies.filter((enemy) => enemy.alive);
|
|
|
|
for (const enemy of livingEnemies) {
|
|
|
|
for (const enemy of livingEnemies) {
|
|
|
|
enemy.fireTimer = Math.max(0, enemy.fireTimer - dt);
|
|
|
|
enemy.fireTimer = Math.max(0, enemy.fireTimer - dt);
|
|
|
|
|
|
|
|
enemy.avoidTimer = Math.max(0, enemy.avoidTimer - dt);
|
|
|
|
enemy.aiTimer -= dt;
|
|
|
|
enemy.aiTimer -= dt;
|
|
|
|
|
|
|
|
|
|
|
|
if (enemy.aiTimer <= 0) {
|
|
|
|
if (enemy.aiTimer <= 0 && enemy.avoidTimer <= 0) {
|
|
|
|
enemy.dir = chooseEnemyDirection(enemy);
|
|
|
|
enemy.dir = chooseEnemyDirection(enemy);
|
|
|
|
enemy.aiTimer = 0.45 + Math.random() * 1.25;
|
|
|
|
enemy.aiTimer = 0.45 + Math.random() * 1.25;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -287,12 +360,16 @@
|
|
|
|
const dir = DIRS[enemy.dir];
|
|
|
|
const dir = DIRS[enemy.dir];
|
|
|
|
const moved = moveTank(enemy, dir.x * ENEMY_SPEED * dt, dir.y * ENEMY_SPEED * dt, livingEnemies);
|
|
|
|
const moved = moveTank(enemy, dir.x * ENEMY_SPEED * dt, dir.y * ENEMY_SPEED * dt, livingEnemies);
|
|
|
|
if (!moved) {
|
|
|
|
if (!moved) {
|
|
|
|
enemy.dir = randomDirection();
|
|
|
|
const escapeDir = chooseOpenEnemyDirection(enemy, livingEnemies);
|
|
|
|
enemy.aiTimer = 0.25;
|
|
|
|
enemy.dir = escapeDir || randomDirection();
|
|
|
|
|
|
|
|
enemy.avoidTimer = 0.55;
|
|
|
|
|
|
|
|
enemy.aiTimer = 0.2;
|
|
|
|
|
|
|
|
const next = DIRS[enemy.dir];
|
|
|
|
|
|
|
|
moveTank(enemy, next.x * ENEMY_SPEED * dt, next.y * ENEMY_SPEED * dt, livingEnemies);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (canSeePlayer(enemy) || Math.random() < dt * 0.18) {
|
|
|
|
if (canSeePlayer(enemy) || Math.random() < dt * 0.18) {
|
|
|
|
facePlayer(enemy);
|
|
|
|
if (enemy.avoidTimer <= 0) facePlayer(enemy);
|
|
|
|
fire(enemy);
|
|
|
|
fire(enemy);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -313,6 +390,35 @@
|
|
|
|
return names[Math.floor(Math.random() * names.length)];
|
|
|
|
return names[Math.floor(Math.random() * names.length)];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function chooseOpenEnemyDirection(enemy, livingEnemies) {
|
|
|
|
|
|
|
|
const preferred = shuffledDirections().sort((a, b) => distanceFromPlayer(enemy, b) - distanceFromPlayer(enemy, a));
|
|
|
|
|
|
|
|
return preferred.find((name) => canMove(enemy, name, livingEnemies));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function shuffledDirections() {
|
|
|
|
|
|
|
|
const names = Object.keys(DIRS);
|
|
|
|
|
|
|
|
for (let i = names.length - 1; i > 0; i -= 1) {
|
|
|
|
|
|
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
|
|
|
|
|
|
[names[i], names[j]] = [names[j], names[i]];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return names;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function distanceFromPlayer(enemy, dirName) {
|
|
|
|
|
|
|
|
const dir = DIRS[dirName];
|
|
|
|
|
|
|
|
const nextX = enemy.x + dir.x * TILE;
|
|
|
|
|
|
|
|
const nextY = enemy.y + dir.y * TILE;
|
|
|
|
|
|
|
|
const dx = nextX - player.x;
|
|
|
|
|
|
|
|
const dy = nextY - player.y;
|
|
|
|
|
|
|
|
return dx * dx + dy * dy;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function canMove(entity, dirName, peerEnemies) {
|
|
|
|
|
|
|
|
const dir = DIRS[dirName];
|
|
|
|
|
|
|
|
const probe = { ...entity, x: entity.x + dir.x * 4, y: entity.y + dir.y * 4 };
|
|
|
|
|
|
|
|
return !isBlocked(probe, peerEnemies.filter((peer) => peer !== entity));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function facePlayer(enemy) {
|
|
|
|
function facePlayer(enemy) {
|
|
|
|
const dx = player.x - enemy.x;
|
|
|
|
const dx = player.x - enemy.x;
|
|
|
|
const dy = player.y - enemy.y;
|
|
|
|
const dy = player.y - enemy.y;
|
|
|
|
@ -355,8 +461,24 @@
|
|
|
|
bullet.y += bullet.vy * dt;
|
|
|
|
bullet.y += bullet.vy * dt;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const canceledBullets = new Set();
|
|
|
|
|
|
|
|
for (let i = 0; i < bullets.length; i += 1) {
|
|
|
|
|
|
|
|
if (canceledBullets.has(i)) continue;
|
|
|
|
|
|
|
|
for (let j = i + 1; j < bullets.length; j += 1) {
|
|
|
|
|
|
|
|
if (canceledBullets.has(j)) continue;
|
|
|
|
|
|
|
|
if (bullets[i].owner === bullets[j].owner) continue;
|
|
|
|
|
|
|
|
if (!intersects(expandedBullet(bullets[i]), expandedBullet(bullets[j]))) continue;
|
|
|
|
|
|
|
|
canceledBullets.add(i);
|
|
|
|
|
|
|
|
canceledBullets.add(j);
|
|
|
|
|
|
|
|
spark((bullets[i].x + bullets[j].x) / 2, (bullets[i].y + bullets[j].y) / 2, "#f7df7b");
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const remaining = [];
|
|
|
|
const remaining = [];
|
|
|
|
for (const bullet of bullets) {
|
|
|
|
for (let index = 0; index < bullets.length; index += 1) {
|
|
|
|
|
|
|
|
if (canceledBullets.has(index)) continue;
|
|
|
|
|
|
|
|
const bullet = bullets[index];
|
|
|
|
const out =
|
|
|
|
const out =
|
|
|
|
bullet.x < -20 || bullet.y < -20 || bullet.x > WIDTH + 20 || bullet.y > HEIGHT + 20;
|
|
|
|
bullet.x < -20 || bullet.y < -20 || bullet.x > WIDTH + 20 || bullet.y > HEIGHT + 20;
|
|
|
|
if (out) continue;
|
|
|
|
if (out) continue;
|
|
|
|
@ -381,9 +503,9 @@
|
|
|
|
continue;
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else if (intersects(bullet, player)) {
|
|
|
|
} else if (intersects(bullet, player)) {
|
|
|
|
lives -= 1;
|
|
|
|
health = Math.max(0, health - PLAYER_HIT_DAMAGE);
|
|
|
|
spark(player.x + player.w / 2, player.y + player.h / 2, "#77b255");
|
|
|
|
spark(player.x + player.w / 2, player.y + player.h / 2, "#77b255");
|
|
|
|
if (lives <= 0) {
|
|
|
|
if (health <= 0) {
|
|
|
|
player.alive = false;
|
|
|
|
player.alive = false;
|
|
|
|
setState("gameOver");
|
|
|
|
setState("gameOver");
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
@ -398,6 +520,16 @@
|
|
|
|
bullets = remaining;
|
|
|
|
bullets = remaining;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function expandedBullet(bullet) {
|
|
|
|
|
|
|
|
const padding = 5;
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
x: bullet.x - padding,
|
|
|
|
|
|
|
|
y: bullet.y - padding,
|
|
|
|
|
|
|
|
w: bullet.w + padding * 2,
|
|
|
|
|
|
|
|
h: bullet.h + padding * 2,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateParticles(dt) {
|
|
|
|
function updateParticles(dt) {
|
|
|
|
particles = particles
|
|
|
|
particles = particles
|
|
|
|
.map((particle) => ({
|
|
|
|
.map((particle) => ({
|
|
|
|
@ -483,20 +615,85 @@
|
|
|
|
ctx.save();
|
|
|
|
ctx.save();
|
|
|
|
ctx.translate(cx, cy);
|
|
|
|
ctx.translate(cx, cy);
|
|
|
|
ctx.rotate(directionAngle(entity.dir));
|
|
|
|
ctx.rotate(directionAngle(entity.dir));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let glowColor, glowBlur, outerColor, innerColor, coreColor;
|
|
|
|
|
|
|
|
|
|
|
|
if (isPlayer) {
|
|
|
|
if (isPlayer) {
|
|
|
|
ctx.shadowColor = "rgba(132, 238, 212, 0.75)";
|
|
|
|
if (health > 66) {
|
|
|
|
ctx.shadowBlur = 12;
|
|
|
|
glowColor = "rgba(132, 238, 212, 0.55)";
|
|
|
|
|
|
|
|
glowBlur = 7;
|
|
|
|
|
|
|
|
outerColor = "#e8fff7";
|
|
|
|
|
|
|
|
innerColor = "#102822";
|
|
|
|
|
|
|
|
coreColor = entity.color;
|
|
|
|
|
|
|
|
} else if (health > 33) {
|
|
|
|
|
|
|
|
glowColor = "rgba(240, 182, 70, 0.45)";
|
|
|
|
|
|
|
|
glowBlur = 6;
|
|
|
|
|
|
|
|
outerColor = "#c8c0a0";
|
|
|
|
|
|
|
|
innerColor = "#1e2818";
|
|
|
|
|
|
|
|
coreColor = "#9a9850";
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 180);
|
|
|
|
|
|
|
|
glowColor = `rgba(212, 93, 69, ${0.42 + 0.32 * pulse})`;
|
|
|
|
|
|
|
|
glowBlur = 7 + 4 * pulse;
|
|
|
|
|
|
|
|
outerColor = "#3a2020";
|
|
|
|
|
|
|
|
innerColor = "#1a0c0c";
|
|
|
|
|
|
|
|
coreColor = "#6b2828";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
ctx.shadowColor = glowColor;
|
|
|
|
|
|
|
|
ctx.shadowBlur = glowBlur;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
ctx.fillStyle = isPlayer ? "#e8fff7" : "#2c0f0a";
|
|
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = isPlayer ? outerColor : "#2c0f0a";
|
|
|
|
ctx.fillRect(-18, -18, 36, 36);
|
|
|
|
ctx.fillRect(-18, -18, 36, 36);
|
|
|
|
ctx.shadowBlur = 0;
|
|
|
|
ctx.shadowBlur = 0;
|
|
|
|
ctx.fillStyle = isPlayer ? "#102822" : "#10140f";
|
|
|
|
ctx.fillStyle = isPlayer ? innerColor : "#10140f";
|
|
|
|
ctx.fillRect(-15, -15, 30, 30);
|
|
|
|
ctx.fillRect(-15, -15, 30, 30);
|
|
|
|
ctx.fillStyle = entity.color;
|
|
|
|
ctx.fillStyle = coreColor || entity.color;
|
|
|
|
ctx.fillRect(-12, -12, 24, 24);
|
|
|
|
ctx.fillRect(-12, -12, 24, 24);
|
|
|
|
ctx.fillStyle = "rgba(255,255,255,0.18)";
|
|
|
|
ctx.fillStyle = "rgba(255,255,255,0.18)";
|
|
|
|
ctx.fillRect(-7, -8, 14, 16);
|
|
|
|
ctx.fillRect(-7, -8, 14, 16);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (isPlayer && health <= 66 && health > 33) {
|
|
|
|
|
|
|
|
ctx.strokeStyle = "rgba(30, 20, 10, 0.55)";
|
|
|
|
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
|
|
ctx.moveTo(-10, -6);
|
|
|
|
|
|
|
|
ctx.lineTo(4, 8);
|
|
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
|
|
ctx.moveTo(6, -10);
|
|
|
|
|
|
|
|
ctx.lineTo(12, -2);
|
|
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
ctx.fillStyle = "rgba(30, 20, 10, 0.4)";
|
|
|
|
|
|
|
|
ctx.fillRect(-8, 4, 5, 5);
|
|
|
|
|
|
|
|
ctx.fillRect(5, -4, 4, 4);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (isPlayer && health <= 33) {
|
|
|
|
|
|
|
|
ctx.strokeStyle = "rgba(80, 10, 10, 0.6)";
|
|
|
|
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
|
|
ctx.moveTo(-12, -8);
|
|
|
|
|
|
|
|
ctx.lineTo(6, 10);
|
|
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
|
|
ctx.moveTo(8, -12);
|
|
|
|
|
|
|
|
ctx.lineTo(-4, 6);
|
|
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
|
|
ctx.moveTo(-6, -12);
|
|
|
|
|
|
|
|
ctx.lineTo(10, 4);
|
|
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
|
|
ctx.moveTo(-10, 2);
|
|
|
|
|
|
|
|
ctx.lineTo(8, -6);
|
|
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = isPlayer ? "#f4fff8" : "#1b0805";
|
|
|
|
ctx.fillStyle = isPlayer ? "#f4fff8" : "#1b0805";
|
|
|
|
|
|
|
|
if (isPlayer && health <= 33 && Math.floor(Date.now() / 300) % 2 === 0) {
|
|
|
|
|
|
|
|
ctx.globalAlpha = 0.3;
|
|
|
|
|
|
|
|
}
|
|
|
|
ctx.fillRect(-4, -26, 8, 20);
|
|
|
|
ctx.fillRect(-4, -26, 8, 20);
|
|
|
|
if (isPlayer) {
|
|
|
|
if (isPlayer) {
|
|
|
|
ctx.fillStyle = "#f4fff8";
|
|
|
|
ctx.fillStyle = "#f4fff8";
|
|
|
|
@ -504,6 +701,15 @@
|
|
|
|
ctx.fillRect(-10, -2, 20, 4);
|
|
|
|
ctx.fillRect(-10, -2, 20, 4);
|
|
|
|
ctx.fillStyle = "#79e5c9";
|
|
|
|
ctx.fillStyle = "#79e5c9";
|
|
|
|
ctx.fillRect(-5, -5, 10, 10);
|
|
|
|
ctx.fillRect(-5, -5, 10, 10);
|
|
|
|
|
|
|
|
if (health <= 33 && Math.floor(Date.now() / 300) % 2 === 0) {
|
|
|
|
|
|
|
|
ctx.fillStyle = "rgba(212, 93, 69, 0.7)";
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
|
|
ctx.moveTo(0, -14);
|
|
|
|
|
|
|
|
ctx.lineTo(5, -6);
|
|
|
|
|
|
|
|
ctx.lineTo(-5, -6);
|
|
|
|
|
|
|
|
ctx.closePath();
|
|
|
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
ctx.fillStyle = "#ffd2c2";
|
|
|
|
ctx.fillStyle = "#ffd2c2";
|
|
|
|
ctx.beginPath();
|
|
|
|
ctx.beginPath();
|
|
|
|
@ -513,6 +719,8 @@
|
|
|
|
ctx.closePath();
|
|
|
|
ctx.closePath();
|
|
|
|
ctx.fill();
|
|
|
|
ctx.fill();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
|
|
|
|
|
|
|
|
ctx.restore();
|
|
|
|
ctx.restore();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@ -559,12 +767,83 @@
|
|
|
|
keys.delete(event.key.toLowerCase());
|
|
|
|
keys.delete(event.key.toLowerCase());
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const stickKeys = ["arrowup", "arrowdown", "arrowleft", "arrowright"];
|
|
|
|
|
|
|
|
let activeStickKey = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function clearStickDirection() {
|
|
|
|
|
|
|
|
if (activeStickKey) keys.delete(activeStickKey);
|
|
|
|
|
|
|
|
activeStickKey = null;
|
|
|
|
|
|
|
|
if (moveKnob) moveKnob.style.transform = "translate(-50%, -50%)";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function setStickDirection(key) {
|
|
|
|
|
|
|
|
if (activeStickKey === key) return;
|
|
|
|
|
|
|
|
if (activeStickKey) keys.delete(activeStickKey);
|
|
|
|
|
|
|
|
activeStickKey = key;
|
|
|
|
|
|
|
|
if (key) keys.add(key);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function updateStick(event) {
|
|
|
|
|
|
|
|
if (!moveStick) return;
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
const rect = moveStick.getBoundingClientRect();
|
|
|
|
|
|
|
|
const centerX = rect.left + rect.width / 2;
|
|
|
|
|
|
|
|
const centerY = rect.top + rect.height / 2;
|
|
|
|
|
|
|
|
const dx = event.clientX - centerX;
|
|
|
|
|
|
|
|
const dy = event.clientY - centerY;
|
|
|
|
|
|
|
|
const distance = Math.hypot(dx, dy);
|
|
|
|
|
|
|
|
const deadZone = rect.width * 0.12;
|
|
|
|
|
|
|
|
if (distance < deadZone) {
|
|
|
|
|
|
|
|
clearStickDirection();
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const key = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "arrowright" : "arrowleft") : dy > 0 ? "arrowdown" : "arrowup";
|
|
|
|
|
|
|
|
setStickDirection(key);
|
|
|
|
|
|
|
|
if (moveKnob) {
|
|
|
|
|
|
|
|
const max = rect.width * 0.24;
|
|
|
|
|
|
|
|
const scale = Math.min(max, distance) / distance;
|
|
|
|
|
|
|
|
moveKnob.style.transform = `translate(calc(-50% + ${dx * scale}px), calc(-50% + ${dy * scale}px))`;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
canvas.focus({ preventScroll: true });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (moveStick) {
|
|
|
|
|
|
|
|
moveStick.addEventListener("pointerdown", (event) => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
moveStick.setPointerCapture?.(event.pointerId);
|
|
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
|
|
// Synthetic or interrupted pointer events may not be capturable.
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
updateStick(event);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
moveStick.addEventListener("pointermove", updateStick);
|
|
|
|
|
|
|
|
for (const type of ["pointerup", "pointercancel", "pointerleave"]) {
|
|
|
|
|
|
|
|
moveStick.addEventListener(type, (event) => {
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
clearStickDirection();
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
moveStick.releasePointerCapture?.(event.pointerId);
|
|
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
|
|
// Some browsers release capture automatically.
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
primaryAction.addEventListener("click", startOrResume);
|
|
|
|
primaryAction.addEventListener("click", startOrResume);
|
|
|
|
if (startAction) startAction.addEventListener("click", startOrResume);
|
|
|
|
if (startAction) startAction.addEventListener("click", startOrResume);
|
|
|
|
if (pauseAction) pauseAction.addEventListener("click", togglePause);
|
|
|
|
if (pauseAction) pauseAction.addEventListener("click", togglePause);
|
|
|
|
if (stopAction) stopAction.addEventListener("click", endGame);
|
|
|
|
if (stopAction) stopAction.addEventListener("click", endGame);
|
|
|
|
if (endAction) endAction.addEventListener("click", endGame);
|
|
|
|
if (endAction) endAction.addEventListener("click", endGame);
|
|
|
|
|
|
|
|
if (fireAction) {
|
|
|
|
|
|
|
|
fireAction.addEventListener("pointerdown", (event) => {
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
canvas.focus({ preventScroll: true });
|
|
|
|
|
|
|
|
if (state === "playing") fire(player);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
regenerateWalls();
|
|
|
|
player = tank(384, 536, "#77b255", "up", "player");
|
|
|
|
player = tank(384, 536, "#77b255", "up", "player");
|
|
|
|
updateHud();
|
|
|
|
updateHud();
|
|
|
|
setState("menu");
|
|
|
|
setState("menu");
|
|
|
|
|