/**
* Arkinoid — Minimalist Architectural Arcade Game
* Designed for ARC.CALENDAR
*/
// Mobile Detection
const isMobile = ('ontouchstart' in window || navigator.maxTouchPoints > 0);
const BASE_PADDLE_WIDTH = isMobile ? 60 : 120;
const HIGHSCORE_KEY = isMobile ? "arkinoid_highscores_mobile" : "arkinoid_highscores_desktop";
// Web Audio API Sound System
class SoundSystem {
constructor() {
this.ctx = null;
this.muted = false;
}
init() {
if (!this.ctx) {
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (AudioContextClass) {
this.ctx = new AudioContextClass();
}
} catch (e) {
console.warn("Web Audio API is not supported in this browser:", e);
}
}
}
playTone(freqStart, freqEnd, type, duration, volume = 0.1) {
if (this.muted) return;
this.init();
if (!this.ctx || this.ctx.state === 'suspended') return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freqStart, this.ctx.currentTime);
if (freqEnd && freqEnd !== freqStart) {
osc.frequency.exponentialRampToValueAtTime(freqEnd, this.ctx.currentTime + duration);
}
gain.gain.setValueAtTime(volume, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + duration);
} catch (e) {
console.warn("Audio play failed:", e);
}
}
playBrickHit() {
this.playTone(300, 150, 'triangle', 0.08, 0.12);
}
playPaddleHit() {
this.playTone(180, 280, 'sine', 0.12, 0.12);
}
playWallHit() {
this.playTone(150, 150, 'sine', 0.08, 0.08);
}
playPowerup() {
this.playTone(300, 600, 'square', 0.22, 0.04);
}
playLaser() {
this.playTone(700, 200, 'sawtooth', 0.08, 0.05);
}
playLoseLife() {
this.playTone(300, 80, 'sawtooth', 0.4, 0.12);
}
playLevelUp() {
this.playTone(523.25, 523.25, 'sine', 0.08, 0.08); // C5
setTimeout(() => this.playTone(659.25, 659.25, 'sine', 0.08, 0.08), 80); // E5
setTimeout(() => this.playTone(783.99, 783.99, 'sine', 0.08, 0.08), 160); // G5
setTimeout(() => this.playTone(1046.50, 1046.50, 'sine', 0.25, 0.08), 240); // C6
}
playGameOver() {
this.playTone(392.00, 196.00, 'sawtooth', 0.5, 0.12); // G4 to G3
}
}
const sounds = new SoundSystem();
// Fallback Mock Data in case API fails
const fallbackCompetitions = [
{ title: "Городской эко-дизайн", image: "https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=300&q=80" },
{ title: "Конкурс небоскребов", image: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=300&q=80" },
{ title: "Дизайн павильона", image: "https://images.unsplash.com/photo-1503387762-592deb58ef4e?auto=format&fit=crop&w=300&q=80" },
{ title: "Расширение музея", image: "https://images.unsplash.com/photo-1558618666-fcd25c85cd6b?auto=format&fit=crop&w=300&q=80" },
{ title: "Эко-деревня", image: "https://images.unsplash.com/photo-1510798831971-661eb04b3739?auto=format&fit=crop&w=300&q=80" },
{ title: "Дом у озера", image: "https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=300&q=80" }
];
// Grid Constants
const BRICK_SIZE = 45;
const COL_SPACING = 55;
const ROW_SPACING = 70;
const PADDLE_HEIGHT = 16;
const BALL_SPEED_START = 5.5;
const MAX_BALL_SPEED = 10;
// Game State Variables
let competitions = [];
let loadedImages = [];
let isImagesLoaded = false;
let score = 0;
let level = 1;
let lives = 3;
let gameOver = false;
let gameWon = false;
let gamePaused = false;
let levelTransitionTimer = 0;
let lastSavedScoreIndex = -1;
const balls = [];
const paddle = {
x: 0,
y: 0,
width: BASE_PADDLE_WIDTH,
height: PADDLE_HEIGHT,
speed: 8,
targetX: 0,
sticky: false,
lasersLeft: 0,
laserCooldown: 0
};
const bricks = [];
const particles = [];
const powerups = [];
const lasers = [];
// Powerup Management
let activePowerup = null; // 'widen', 'sticky', 'laser'
let powerupTimer = 0;
// DOM Elements
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const overlay = document.getElementById("game-overlay");
const overlayContent = document.getElementById("overlay-content");
const overlayTitle = document.getElementById("overlay-title");
const overlayDesc = document.getElementById("overlay-desc");
const overlayLoader = document.getElementById("overlay-loader");
const overlayActions = document.getElementById("overlay-actions");
const overlayLeaderboard = document.getElementById("overlay-leaderboard");
const leaderboardTitle = document.getElementById("leaderboard-title");
const leaderboardEntries = document.getElementById("leaderboard-entries");
const btnStart = document.getElementById("btn-start");
// Name Input Overlay Elements
const overlayRecordInput = document.getElementById("overlay-record-input");
const playerNameInput = document.getElementById("player-name-input");
const btnSubmitName = document.getElementById("btn-submit-name");
const soundToggle = document.getElementById("sound-toggle");
const soundIcon = document.getElementById("sound-icon");
const hudScore = document.getElementById("hud-score");
const hudLives = document.getElementById("hud-lives");
const activePowerupCard = document.getElementById("active-powerup-card");
const powerupName = document.getElementById("powerup-name");
const powerupProgress = document.getElementById("powerup-progress");
const pauseIndicator = document.getElementById("pause-indicator");
const mobTouchHints = document.getElementById("mob-touch-hints");
// Controls State
const keys = { ArrowLeft: false, ArrowRight: false, KeyA: false, KeyD: false };
let pointerX = null;
// --- Helper: Generate 3-Letter Upper Abbreviations ---
function getContestLabel(title) {
if (!title) return "CON";
const clean = title.replace(/[^a-zA-Zа-яА-Я0-9\s]/g, "").trim();
const words = clean.split(/\s+/).filter(w => w.length > 0);
if (words.length >= 3) {
return (words[0][0] + words[1][0] + words[2][0]).toUpperCase();
} else if (words.length === 2) {
return (words[0].substring(0, 2) + words[1][0]).toUpperCase();
} else if (words[0] && words[0].length >= 3) {
return words[0].substring(0, 3).toUpperCase();
}
return "CON";
}
// --- HUD Updates ---
function updateLivesHUD() {
if (hudLives) {
hudLives.innerHTML = "";
for (let i = 0; i < lives; i++) {
const dot = document.createElement("span");
dot.innerText = "●";
dot.style.color = "#D76F6A";
dot.style.fontSize = "20px";
dot.style.lineHeight = "1";
hudLives.appendChild(dot);
}
}
}
// --- Initialization ---
async function fetchCompetitions() {
try {
const response = await fetch("https://arc-calendar-api.perov-nik.workers.dev/");
if (!response.ok) throw new Error("API failed");
const data = await response.json();
const records = data.records || (Array.isArray(data) ? data : []);
competitions = records.map(record => {
const f = record.fields;
let imgUrl = "";
if (f["Ссылка на картинку"]) imgUrl = f["Ссылка на картинку"];
else if (f["Картинка"] && Array.isArray(f["Картинка"]) && f["Картинка"].length > 0) {
imgUrl = f["Картинка"][0].url || f["Картинка"][0].thumbnails?.large?.url || "";
}
if (!imgUrl || imgUrl.includes("unsplash.com")) {
imgUrl = "https://images.unsplash.com/photo-1558618666-fcd25c85cd6b?auto=format&fit=crop&w=300&q=80";
}
return {
title: f["Название конкурса"] || "Конкурс",
image: imgUrl
};
}).filter(c => c.image);
if (competitions.length === 0) throw new Error("No items parsed");
} catch (e) {
console.warn("API Error, using fallback:", e);
competitions = [...fallbackCompetitions];
}
}
async function preloadImages() {
overlayLoader.classList.remove("hidden");
overlayActions.classList.add("hidden");
overlayLeaderboard.classList.add("hidden");
overlayRecordInput.classList.add("hidden");
overlayDesc.innerText = "LOADING COVERS...";
await fetchCompetitions();
let loadedCount = 0;
const promises = competitions.map(comp => {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
loadedImages.push(img);
loadedCount++;
overlayDesc.innerText = `LOADED: ${loadedCount} OF ${competitions.length}`;
resolve();
};
img.onerror = () => {
const canvasDummy = document.createElement("canvas");
canvasDummy.width = 10;
canvasDummy.height = 10;
const ctxDummy = canvasDummy.getContext("2d");
ctxDummy.fillStyle = "#3A6B75";
ctxDummy.fillRect(0, 0, 10, 10);
const dummyImg = new Image();
dummyImg.onload = () => {
loadedImages.push(dummyImg);
loadedCount++;
resolve();
};
dummyImg.onerror = () => {
loadedCount++;
resolve();
};
dummyImg.src = canvasDummy.toDataURL();
};
img.src = comp.image;
});
});
const timeoutPromise = new Promise((resolve) => {
setTimeout(() => {
console.warn("Image load timeout. Proceeding.");
resolve();
}, 6000);
});
await Promise.race([Promise.all(promises), timeoutPromise]);
if (loadedImages.length === 0) {
const canvasDummy = document.createElement("canvas");
canvasDummy.width = 10;
canvasDummy.height = 10;
const ctxDummy = canvasDummy.getContext("2d");
ctxDummy.fillStyle = "#3A6B75";
ctxDummy.fillRect(0, 0, 10, 10);
const dummyImg = new Image();
await new Promise((res) => {
dummyImg.onload = res;
dummyImg.onerror = res;
dummyImg.src = canvasDummy.toDataURL();
});
loadedImages.push(dummyImg);
}
isImagesLoaded = true;
overlayLoader.classList.add("hidden");
overlayActions.classList.remove("hidden");
overlayDesc.innerText = "COVERS LOADED. READY?";
}
// --- Layouts Configurations ---
function getLevelLayout(lvl) {
const layout = [];
if (lvl === 1) {
// Level 1: Pyramid of Cheops
const cheopsColors = ["#e6194B", "#D76F6A", "#e07a10", "#f032e6", "#3cb44b", "#008080"];
for (let r = -3; r <= 1; r++) {
const width = 1 + (r + 3) * 2;
const color = cheopsColors[r + 3] || "#3A6B75";
for (let c = -Math.floor(width/2); c <= Math.floor(width/2); c++) {
layout.push({ col: c, row: r, color: color });
}
}
} else if (lvl === 2) {
// Level 2: Space Invader
const invader = [
{ col: -2, row: -3.5, color: "#e07a10" },
{ col: 2, row: -3.5, color: "#e07a10" },
{ col: -2, row: -2.5, color: "#e07a10" },
{ col: 2, row: -2.5, color: "#e07a10" },
{ col: -3, row: -1.5, color: "#e6194B" },
{ col: -2, row: -1.5, color: "#e6194B" },
{ col: 2, row: -1.5, color: "#e6194B" },
{ col: 3, row: -1.5, color: "#e6194B" },
{ col: -4, row: -0.5, color: "#f032e6" },
{ col: -3, row: -0.5, color: "#f032e6" },
{ col: -1, row: -0.5, color: "#f032e6" },
{ col: 1, row: -0.5, color: "#f032e6" },
{ col: 3, row: -0.5, color: "#f032e6" },
{ col: 4, row: -0.5, color: "#f032e6" },
{ col: -4, row: 0.5, color: "#3cb44b" },
{ col: -3, row: 0.5, color: "#3cb44b" },
{ col: -2, row: 0.5, color: "#3cb44b" },
{ col: -1, row: 0.5, color: "#3cb44b" },
{ col: 1, row: 0.5, color: "#3cb44b" },
{ col: 2, row: 0.5, color: "#3cb44b" },
{ col: 3, row: 0.5, color: "#3cb44b" },
{ col: 4, row: 0.5, color: "#3cb44b" },
{ col: -3, row: 1.5, color: "#008080" },
{ col: 3, row: 1.5, color: "#008080" },
{ col: -4, row: 2.5, color: "#008080" },
{ col: 4, row: 2.5, color: "#008080" },
{ col: -2, row: 3.5, color: "#008080" },
{ col: -1, row: 3.5, color: "#008080" },
{ col: 1, row: 3.5, color: "#008080" },
{ col: 2, row: 3.5, color: "#008080" }
];
layout.push(...invader);
} else if (lvl === 3) {
// Level 3: Architectural Ziggurat
const colors = ["#e6194B", "#D76F6A", "#e07a10", "#f032e6", "#3cb44b", "#008080"];
for (let r = -3; r <= 2; r++) {
const width = 3 + (r + 3) * 2;
const color = colors[r + 3] || "#3A6B75";
for (let c = -width/2; c < width/2; c++) {
layout.push({ col: c + 0.5, row: r, color: color });
}
}
} else {
// Level 4: The letter K
const kColor = "#D76F6A";
for (let r = -3; r <= 3; r++) {
layout.push({ col: -2, row: r, color: kColor });
}
layout.push({ col: -1, row: -1, color: kColor });
layout.push({ col: 0, row: -2, color: kColor });
layout.push({ col: 1, row: -3, color: kColor });
layout.push({ col: -1, row: 1, color: kColor });
layout.push({ col: 0, row: 2, color: kColor });
layout.push({ col: 1, row: 3, color: kColor });
layout.push({ col: -1, row: 0, color: kColor });
}
return layout;
}
function initLevel() {
bricks.length = 0;
powerups.length = 0;
lasers.length = 0;
particles.length = 0;
const layout = getLevelLayout(level);
const imgCount = loadedImages.length || 1;
layout.forEach((cell, idx) => {
const imgIndex = idx % imgCount;
const compInfo = competitions[imgIndex] || fallbackCompetitions[imgIndex % fallbackCompetitions.length];
bricks.push({
gridX: cell.col * COL_SPACING,
gridY: cell.row * ROW_SPACING,
x: 0,
y: 0,
width: BRICK_SIZE,
height: BRICK_SIZE,
image: loadedImages[imgIndex],
label: getContestLabel(compInfo?.title),
fallbackColor: cell.color,
hits: 1,
maxHits: 1,
active: true
});
});
deactivatePowerup();
resize();
resetBallAndPaddle();
updateLivesHUD();
}
function resetBallAndPaddle() {
balls.length = 0;
paddle.width = BASE_PADDLE_WIDTH;
paddle.x = (canvas.width - paddle.width) / 2;
paddle.y = canvas.height - 60;
paddle.sticky = false;
paddle.lasersLeft = 0;
paddle.laserCooldown = 0;
balls.push({
x: canvas.width / 2,
y: paddle.y - 12,
vx: 0,
vy: 0,
radius: 6,
active: true,
stuck: true,
stuckOffset: BASE_PADDLE_WIDTH / 2
});
// Restore touch hints on mobile when not started
if (isMobile) {
mobTouchHints.classList.remove("hidden");
mobTouchHints.classList.add("flex");
}
}
function launchBall() {
let launchedAny = false;
balls.forEach(ball => {
if (ball.stuck) {
const angle = (Math.random() * 40 - 20) * Math.PI / 180;
const speed = BALL_SPEED_START + level * 0.5;
ball.vx = speed * Math.sin(angle);
ball.vy = -speed * Math.cos(angle);
ball.stuck = false;
launchedAny = true;
}
});
if (launchedAny) {
sounds.playPaddleHit();
if (isMobile) {
mobTouchHints.classList.add("hidden");
mobTouchHints.classList.remove("flex");
}
}
}
function createExplosion(x, y, w, h, fallbackColor) {
const particleCount = 20;
for (let i = 0; i < particleCount; i++) {
particles.push({
x: x + w / 2,
y: y + h / 2,
vx: (Math.random() - 0.5) * 5,
vy: (Math.random() - 0.5) * 5,
size: Math.random() * 4 + 2,
color: fallbackColor || "#3A6B75",
alpha: 1,
decay: Math.random() * 0.04 + 0.03
});
}
}
const POWERUP_TYPES = [
{ type: 'widen', color: '#3A6B75', icon: '↔', label: 'Длинная ракетка' },
{ type: 'multiball', color: '#3A6B75', icon: '☄', label: 'Мульти-шар' },
{ type: 'sticky', color: '#3A6B75', icon: '★', label: 'Липкая ракетка' },
{ type: 'laser', color: '#3A6B75', icon: '⚡', label: 'Лазерные лучи' },
{ type: 'life', color: '#3A6B75', icon: '♥', label: 'Дополнительная жизнь' }
];
function spawnPowerup(x, y) {
if (Math.random() > 0.3) return;
const typeInfo = POWERUP_TYPES[Math.floor(Math.random() * POWERUP_TYPES.length)];
powerups.push({
x: x,
y: y,
vy: 2.2,
width: 24,
height: 24,
type: typeInfo.type,
color: typeInfo.color,
icon: typeInfo.icon,
label: typeInfo.label
});
}
function deactivatePowerup() {
paddle.width = BASE_PADDLE_WIDTH;
paddle.sticky = false;
paddle.lasersLeft = 0;
activePowerupCard.classList.add("hidden");
activePowerup = null;
powerupTimer = 0;
}
function activatePowerup(type, label, icon) {
sounds.playPowerup();
deactivatePowerup();
activePowerupCard.classList.remove("hidden");
powerupName.innerText = label.toUpperCase();
if (type === 'widen') {
activePowerup = 'widen';
paddle.width = Math.round(BASE_PADDLE_WIDTH * 1.5);
powerupTimer = 600; // 10 seconds
} else if (type === 'sticky') {
activePowerup = 'sticky';
paddle.sticky = true;
powerupTimer = 600; // 10 seconds
} else if (type === 'laser') {
activePowerup = 'laser';
paddle.lasersLeft = 6;
powerupProgress.style.width = "100%";
} else if (type === 'multiball') {
activePowerupCard.classList.add("hidden");
const refBall = balls[0] || { x: canvas.width/2, y: paddle.y - 12, vx: 0, vy: -5, stuck: false };
const isRefStuck = !!refBall.stuck;
const speed = BALL_SPEED_START + level * 0.5;
const baseVy = refBall.vy !== 0 ? -Math.abs(refBall.vy) : -speed;
const baseVx = refBall.vx;
balls.push({
x: refBall.x,
y: refBall.y,
vx: isRefStuck ? 0 : baseVx + (Math.random() * 2 - 1),
vy: isRefStuck ? 0 : baseVy,
radius: 6,
active: true,
stuck: isRefStuck,
stuckOffset: isRefStuck ? Math.max(0, Math.min(paddle.width, refBall.stuckOffset - 20)) : 0
});
balls.push({
x: refBall.x,
y: refBall.y,
vx: isRefStuck ? 0 : baseVx + (Math.random() * 2 - 1),
vy: isRefStuck ? 0 : baseVy,
radius: 6,
active: true,
stuck: isRefStuck,
stuckOffset: isRefStuck ? Math.max(0, Math.min(paddle.width, refBall.stuckOffset + 20)) : 0
});
} else if (type === 'life') {
activePowerupCard.classList.add("hidden");
lives++;
updateLivesHUD();
}
}
function fireLaser() {
if (paddle.lasersLeft <= 0 || paddle.laserCooldown > 0) return;
lasers.push({ x: paddle.x + 10, y: paddle.y - 10, vy: -8 });
lasers.push({ x: paddle.x + paddle.width - 10, y: paddle.y - 10, vy: -8 });
paddle.lasersLeft--;
paddle.laserCooldown = 20;
sounds.playLaser();
const pct = (paddle.lasersLeft / 6) * 100;
powerupProgress.style.width = `${pct}%`;
if (paddle.lasersLeft <= 0) {
deactivatePowerup();
}
}
function isColliding(rect1, rect2) {
return rect1.x < rect2.x + rect2.width &&
rect1.x + rect1.width > rect2.x &&
rect1.y < rect2.y + rect2.height &&
rect1.y + rect1.height > rect2.y;
}
function update() {
if (gameOver || gameWon || gamePaused) return;
if (levelTransitionTimer > 0) {
levelTransitionTimer--;
if (levelTransitionTimer === 0) {
initLevel();
}
return;
}
if (powerupTimer > 0) {
powerupTimer--;
const pct = (powerupTimer / 600) * 100;
powerupProgress.style.width = `${pct}%`;
if (powerupTimer === 0) {
deactivatePowerup();
}
}
let moveDir = 0;
if (keys.ArrowLeft || keys.KeyA) moveDir = -1;
if (keys.ArrowRight || keys.KeyD) moveDir = 1;
if (moveDir !== 0) {
paddle.x += moveDir * paddle.speed;
} else if (pointerX !== null) {
const targetX = pointerX - paddle.width / 2;
paddle.x += (targetX - paddle.x) * 0.28;
}
if (paddle.x < 0) paddle.x = 0;
if (paddle.x > canvas.width - paddle.width) paddle.x = canvas.width - paddle.width;
if (paddle.laserCooldown > 0) paddle.laserCooldown--;
for (let i = lasers.length - 1; i >= 0; i--) {
const l = lasers[i];
l.y += l.vy;
let hit = false;
for (let j = 0; j < bricks.length; j++) {
const b = bricks[j];
if (b.active && isColliding({ x: l.x - 2, y: l.y - 10, width: 4, height: 10 }, b)) {
hit = true;
b.hits--;
sounds.playBrickHit();
if (b.hits <= 0) {
b.active = false;
createExplosion(b.x, b.y, b.width, b.height, b.fallbackColor);
spawnPowerup(b.x + b.width/2, b.y + b.height/2);
score += 100 * level;
hudScore.innerText = score;
}
break;
}
}
if (hit || l.y < 0) {
lasers.splice(i, 1);
}
}
for (let i = balls.length - 1; i >= 0; i--) {
const ball = balls[i];
if (!ball.active) continue;
if (ball.stuck) {
ball.x = paddle.x + (ball.stuckOffset !== undefined ? ball.stuckOffset : paddle.width / 2);
ball.y = paddle.y - ball.radius;
continue;
}
ball.x += ball.vx;
ball.y += ball.vy;
if (ball.x - ball.radius < 0) {
ball.x = ball.radius;
ball.vx = -ball.vx;
sounds.playWallHit();
}
if (ball.x + ball.radius > canvas.width) {
ball.x = canvas.width - ball.radius;
ball.vx = -ball.vx;
sounds.playWallHit();
}
if (ball.y - ball.radius < 0) {
ball.y = ball.radius;
ball.vy = -ball.vy;
sounds.playWallHit();
}
if (ball.y - ball.radius > canvas.height) {
ball.active = false;
balls.splice(i, 1);
if (balls.length === 0) {
lives--;
updateLivesHUD();
sounds.playLoseLife();
if (lives <= 0) {
gameOver = true;
sounds.playGameOver();
const scores = loadHighScores();
const isNewHighScore = scores.length === 0 || score > 0;
if (isNewHighScore) {
setTimeout(() => {
showOverlayRecordInput();
}, 500);
} else {
showOverlayWithActions("GAME OVER", `YOU SCORED ${score} POINTS.`);
}
} else {
resetBallAndPaddle();
}
}
continue;
}
if (ball.vy > 0 &&
ball.y + ball.radius >= paddle.y &&
ball.y - ball.radius <= paddle.y + paddle.height &&
ball.x >= paddle.x &&
ball.x <= paddle.x + paddle.width) {
if (paddle.sticky) {
ball.stuck = true;
ball.stuckOffset = Math.max(0, Math.min(paddle.width, ball.x - paddle.x));
ball.vx = 0;
ball.vy = 0;
} else {
const hitPoint = (ball.x - paddle.x) / paddle.width;
const angle = (hitPoint - 0.5) * (Math.PI * 0.45);
const speed = Math.min(Math.sqrt(ball.vx*ball.vx + ball.vy*ball.vy) + 0.15, MAX_BALL_SPEED);
ball.vx = speed * Math.sin(angle);
ball.vy = -speed * Math.cos(angle);
sounds.playPaddleHit();
}
}
for (let j = 0; j < bricks.length; j++) {
const b = bricks[j];
if (!b.active) continue;
const ballBox = { x: ball.x - ball.radius, y: ball.y - ball.radius, width: ball.radius*2, height: ball.radius*2 };
if (isColliding(ballBox, b)) {
b.hits--;
sounds.playBrickHit();
const ballPrevX = ball.x - ball.vx;
if (ballPrevX + ball.radius <= b.x || ballPrevX - ball.radius >= b.x + b.width) {
ball.vx = -ball.vx;
} else {
ball.vy = -ball.vy;
}
if (b.hits <= 0) {
b.active = false;
createExplosion(b.x, b.y, b.width, b.height, b.fallbackColor);
spawnPowerup(b.x + b.width/2, b.y + b.height/2);
score += 100 * level;
hudScore.innerText = score;
}
break;
}
}
}
for (let i = powerups.length - 1; i >= 0; i--) {
const drop = powerups[i];
drop.y += drop.vy;
const paddleBox = { x: paddle.x, y: paddle.y, width: paddle.width, height: paddle.height };
if (isColliding(drop, paddleBox)) {
activatePowerup(drop.type, drop.label, drop.icon);
powerups.splice(i, 1);
continue;
}
if (drop.y > canvas.height) {
powerups.splice(i, 1);
}
}
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.alpha -= p.decay;
if (p.alpha <= 0) {
particles.splice(i, 1);
}
}
const activeBricks = bricks.filter(b => b.active).length;
if (activeBricks === 0 && bricks.length > 0) {
level++;
sounds.playLevelUp();
if (level > 4) {
gameWon = true;
const scores = loadHighScores();
const isNewHighScore = scores.length === 0 || score > 0;
if (isNewHighScore) {
setTimeout(() => {
showOverlayRecordInput();
}, 500);
} else {
showOverlayWithActions("VICTORY!", `CONGRATULATIONS! SCORE: ${score}`);
}
} else {
balls.length = 0;
powerups.length = 0;
lasers.length = 0;
particles.length = 0;
levelTransitionTimer = 120;
}
}
}
// --- Draw Loop ---
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (levelTransitionTimer > 0) {
ctx.save();
ctx.fillStyle = "#3A6B75";
ctx.font = "24px 'Silkscreen', monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`LEVEL ${level}`, canvas.width / 2, canvas.height / 2);
ctx.restore();
return;
}
// 1. Draw Bricks
for (let i = 0; i < bricks.length; i++) {
const b = bricks[i];
if (!b.active) continue;
ctx.save();
if (b.image && b.image.complete && b.image.width > 0) {
ctx.drawImage(b.image, b.x, b.y, b.width, b.height);
} else {
ctx.fillStyle = b.fallbackColor || "#3A6B75";
ctx.fillRect(b.x, b.y, b.width, b.height);
}
ctx.strokeStyle = "#3A6B75";
ctx.lineWidth = 1;
ctx.strokeRect(b.x, b.y, b.width, b.height);
ctx.fillStyle = "#3A6B75";
const baseFontSize = 8;
const scale = b.width / BRICK_SIZE;
const fontSize = Math.max(6, Math.round(baseFontSize * scale));
ctx.font = `${fontSize}px 'Silkscreen', monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText(b.label, b.x + b.width / 2, b.y + b.height + 4);
ctx.restore();
}
// 2. Draw Lasers
ctx.fillStyle = "#D76F6A";
for (let i = 0; i < lasers.length; i++) {
const l = lasers[i];
ctx.fillRect(l.x - 2, l.y - 10, 4, 10);
}
// 3. Draw Drops (Powerups)
for (let i = 0; i < powerups.length; i++) {
const drop = powerups[i];
ctx.save();
ctx.fillStyle = "#3A6B75";
ctx.fillRect(drop.x, drop.y, drop.width, drop.height);
ctx.fillStyle = "#ffffff";
ctx.font = "11px 'Silkscreen', monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(drop.icon, drop.x + drop.width/2, drop.y + drop.height/2);
ctx.restore();
}
// 4. Draw Particles
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
ctx.save();
ctx.globalAlpha = p.alpha;
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, p.size, p.size);
ctx.restore();
}
// 5. Draw Paddle
ctx.save();
ctx.fillStyle = "#3A6B75";
ctx.fillRect(paddle.x, paddle.y, paddle.width, paddle.height);
if (paddle.lasersLeft > 0) {
ctx.fillStyle = "#D76F6A";
ctx.fillRect(paddle.x + 4, paddle.y - 4, 6, 4);
ctx.fillRect(paddle.x + paddle.width - 10, paddle.y - 4, 6, 4);
}
ctx.restore();
// 6. Draw Ball
ctx.fillStyle = "#3A6B75";
for (let i = 0; i < balls.length; i++) {
const ball = balls[i];
if (!ball.active) continue;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
ctx.fill();
}
}
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
paddle.y = canvas.height - 60;
let scale = 1.0;
let minCol = 0;
let maxCol = 0;
bricks.forEach(cell => {
const col = cell.gridX / COL_SPACING;
if (col < minCol) minCol = col;
if (col > maxCol) maxCol = col;
});
const colSpan = (maxCol - minCol + 1.2);
const requiredWidth = colSpan * COL_SPACING;
const availableWidth = canvas.width - 24;
if (requiredWidth > availableWidth) {
scale = availableWidth / requiredWidth;
}
bricks.forEach(b => {
b.width = Math.round(BRICK_SIZE * scale);
b.height = Math.round(BRICK_SIZE * scale);
b.x = Math.round(canvas.width / 2 + b.gridX * scale - b.width / 2);
b.y = Math.round(canvas.height / 2 + b.gridY * scale - b.height / 2 - 80);
});
if (paddle.x > canvas.width - paddle.width) {
paddle.x = canvas.width - paddle.width;
}
}
// --- Overlay Screen Management ---
function showOverlay(title, desc, showLeaderboard = true) {
overlayTitle.innerText = title;
overlayDesc.innerText = desc;
overlayRecordInput.classList.add("hidden");
overlay.classList.remove("opacity-0", "pointer-events-none");
overlay.classList.add("opacity-100");
overlayContent.classList.remove("scale-95");
overlayContent.classList.add("scale-100");
const scores = loadHighScores();
if (showLeaderboard && scores.length > 0) {
overlayLeaderboard.classList.remove("hidden");
renderHighScoresOverlay();
} else {
overlayLeaderboard.classList.add("hidden");
}
}
function showOverlayWithActions(title, desc, showLeaderboard = true) {
overlayActions.classList.remove("hidden");
showOverlay(title, desc, showLeaderboard);
}
function showOverlayRecordInput() {
overlayTitle.innerText = "NEW RECORD!";
overlayDesc.innerText = `YOUR SCORE: ${score}`;
overlayLeaderboard.classList.add("hidden");
overlayActions.classList.add("hidden");
overlayRecordInput.classList.remove("hidden");
playerNameInput.value = "";
overlay.classList.remove("opacity-0", "pointer-events-none");
overlay.classList.add("opacity-100");
overlayContent.classList.remove("scale-95");
overlayContent.classList.add("scale-100");
setTimeout(() => {
playerNameInput.focus();
}, 100);
}
function submitPlayerName() {
const rawName = playerNameInput.value;
const cleanName = rawName.trim().substring(0, 19).toUpperCase() || "PLAYER";
saveHighScore(cleanName);
sounds.playPaddleHit();
overlayRecordInput.classList.add("transition-all", "duration-300", "opacity-0");
setTimeout(() => {
overlayRecordInput.classList.add("hidden");
overlayRecordInput.classList.remove("opacity-0");
overlayTitle.innerText = "HIGHSCORE";
overlayDesc.innerText = `YOUR SCORE: ${score}`;
overlayLeaderboard.classList.remove("hidden");
overlayLeaderboard.classList.add("opacity-0");
renderHighScoresOverlay();
overlayActions.classList.remove("hidden");
overlayActions.classList.add("opacity-0");
setTimeout(() => {
overlayLeaderboard.classList.add("transition-all", "duration-300", "opacity-100");
overlayLeaderboard.classList.remove("opacity-0");
overlayActions.classList.add("transition-all", "duration-300", "opacity-100");
overlayActions.classList.remove("opacity-0");
}, 50);
}, 300);
}
function hideOverlay() {
overlay.classList.remove("opacity-100");
overlay.classList.add("opacity-0", "pointer-events-none");
overlayContent.classList.remove("scale-100");
overlayContent.classList.add("scale-95");
}
function loadHighScores() {
const data = localStorage.getItem(HIGHSCORE_KEY);
return data ? JSON.parse(data) : [];
}
function saveHighScore(name) {
if (score === 0) return;
const scores = loadHighScores();
const cleanName = (name || "PLAYER").substring(0, 19).toUpperCase();
const newEntry = { name: cleanName, score: score, timestamp: Date.now() };
scores.push(newEntry);
scores.sort((a, b) => b.score - a.score);
localStorage.setItem(HIGHSCORE_KEY, JSON.stringify(scores));
lastSavedScoreIndex = scores.findIndex(e => e.timestamp === newEntry.timestamp);
}
function renderHighScoresOverlay() {
if (leaderboardTitle) {
leaderboardTitle.innerText = isMobile ? "HIGHSCORE (MOBILE)" : "HIGHSCORE (DESKTOP)";
}
const scores = loadHighScores();
leaderboardEntries.innerHTML = "";
if (scores.length === 0) {
leaderboardEntries.innerHTML = `
NO RECORDS YET
`;
return;
}
const topLimit = Math.min(10, scores.length);
for (let i = 0; i < topLimit; i++) {
const entry = scores[i];
const rank = i + 1;
const isCurrent = (i === lastSavedScoreIndex);
const item = document.createElement("div");
item.className = `flex items-center justify-between ${isCurrent ? "font-bold text-[#D76F6A]" : "text-[#3A6B75]"}`;
item.innerHTML = `
${rank}. ${entry.name || "PLAYER"} ${entry.score}
`;
leaderboardEntries.appendChild(item);
}
if (lastSavedScoreIndex >= 10 && lastSavedScoreIndex < scores.length) {
const gap = document.createElement("div");
gap.className = "text-center text-xs text-[#3A6B75]/40 tracking-widest my-1 select-none";
gap.innerText = "• • •";
leaderboardEntries.appendChild(gap);
const entry = scores[lastSavedScoreIndex];
const rank = lastSavedScoreIndex + 1;
const item = document.createElement("div");
item.className = "flex items-center justify-between font-bold text-[#D76F6A]";
item.innerHTML = `
${rank}. ${entry.name || "PLAYER"} ${entry.score}
`;
leaderboardEntries.appendChild(item);
}
}
function setupControls() {
window.addEventListener("keydown", (e) => {
const isTyping = (document.activeElement === playerNameInput);
if (e.code === "Space" && !isTyping) {
const hasStuck = balls.some(b => b.stuck);
if (hasStuck && !gameOver && !gameWon && !gamePaused && levelTransitionTimer === 0) {
launchBall();
}
if (paddle.lasersLeft > 0 && !hasStuck && !gamePaused) {
fireLaser();
}
e.preventDefault();
}
if (e.code === "Enter" && isTyping) {
submitPlayerName();
e.preventDefault();
}
if (e.code in keys && !isTyping) {
keys[e.code] = true;
e.preventDefault();
}
if ((e.code === "Escape" || e.code === "KeyP") && !isTyping) {
togglePause();
}
});
window.addEventListener("keyup", (e) => {
if (e.code in keys) {
keys[e.code] = false;
}
});
const trackPointer = (clientX) => {
const relativeX = clientX / window.innerWidth;
pointerX = relativeX * canvas.width;
};
window.addEventListener("mousemove", (e) => {
trackPointer(e.clientX);
});
window.addEventListener("mousedown", (e) => {
if (e.target.closest("button") || e.target.closest("a") || e.target.closest("input")) return;
const hasStuck = balls.some(b => b.stuck);
if (hasStuck && !gameOver && !gameWon && !gamePaused && levelTransitionTimer === 0) {
launchBall();
} else if (paddle.lasersLeft > 0 && !hasStuck && !gamePaused) {
fireLaser();
}
});
window.addEventListener("touchstart", (e) => {
if (e.touches.length > 0) {
trackPointer(e.touches[0].clientX);
}
}, { passive: true });
window.addEventListener("touchmove", (e) => {
if (e.touches.length > 0) {
trackPointer(e.touches[0].clientX);
}
}, { passive: true });
window.addEventListener("touchend", () => {
pointerX = null;
});
soundToggle.addEventListener("click", () => {
sounds.muted = !sounds.muted;
soundIcon.innerText = sounds.muted ? "🔇" : "🔊";
if (!sounds.muted) sounds.init();
});
btnSubmitName.addEventListener("click", () => {
submitPlayerName();
});
btnStart.addEventListener("click", () => {
if (gameOver || gameWon) {
score = 0;
level = 1;
lives = 3;
gameOver = false;
gameWon = false;
lastSavedScoreIndex = -1;
hudScore.innerText = score;
updateLivesHUD();
}
hideOverlay();
initLevel();
sounds.init();
});
}
function togglePause() {
if (gameOver || gameWon || levelTransitionTimer > 0) return;
gamePaused = !gamePaused;
if (gamePaused) {
pauseIndicator.style.opacity = "1";
} else {
pauseIndicator.style.opacity = "0";
}
}
function loop(timestamp) {
update();
draw();
requestAnimationFrame(loop);
}
// detect touch devices
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
mobTouchHints.classList.remove("hidden");
mobTouchHints.classList.add("flex");
}
window.addEventListener("DOMContentLoaded", () => {
resize();
window.addEventListener("resize", resize);
setupControls();
updateLivesHUD();
preloadImages().then(() => {
showOverlayWithActions("ARKINOID", "DESTROY COMPETITION COVERS AND SET THE BEST HIGH SCORE.", false);
});
requestAnimationFrame(loop);
});