- Add PALETTES object with MAGENTA, CYAN, LIME, WHITE presets - Add PALETTE_KEYS array for palette cycling - Change gameState initial value from 'modeSelect' to 'title' - Add activePalette, soundEnabled, selectedMenu, previousState to GameState - Initialize GameState.activePalette to MAGENTA preset after GameConfig is defined
572 lines
21 KiB
HTML
572 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Super Pong Next Gen</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { background: #000; overflow: hidden; width: 100vw; height: 100vh; }
|
|
#gameCanvas { display: block; background: #000; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="gameCanvas"></canvas>
|
|
<script>
|
|
const GameConfig = {
|
|
initialBallSpeed: 220, // px/s — starting speed per serve
|
|
speedIncrement: 18, // px/s added per paddle hit
|
|
paddleSpeed: 400, // px/s — Player 1 paddle movement
|
|
|
|
WIN_SCORE: 7,
|
|
|
|
AI_EASY: { speed: 200, reactionDelay: 0.3, errorMargin: 20 },
|
|
AI_MEDIUM: { speed: 320, reactionDelay: 0.1, errorMargin: 5 },
|
|
AI_HARD: { speed: 400, reactionDelay: 0.05, errorMargin: 2 },
|
|
|
|
PALETTES: {
|
|
MAGENTA: { accent: '#ff00cc', glow: 'rgba(255,0,204,0.6)', name: 'Magenta' },
|
|
CYAN: { accent: '#00ffff', glow: 'rgba(0,255,255,0.6)', name: 'Cyan' },
|
|
LIME: { accent: '#00ff00', glow: 'rgba(0,255,0,0.6)', name: 'Lime' },
|
|
WHITE: { accent: '#ffffff', glow: 'rgba(255,255,255,0.6)', name: 'White' }
|
|
},
|
|
PALETTE_KEYS: ['MAGENTA', 'CYAN', 'LIME', 'WHITE']
|
|
};
|
|
|
|
const Renderer = {
|
|
canvas: null,
|
|
ctx: null,
|
|
logicalWidth: 0,
|
|
logicalHeight: 0,
|
|
|
|
init() {
|
|
this.canvas = document.getElementById('gameCanvas');
|
|
this.resize();
|
|
window.addEventListener('resize', () => this.resize());
|
|
},
|
|
|
|
resize() {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const w = window.innerWidth;
|
|
const h = window.innerHeight;
|
|
const minAspect = 4 / 3;
|
|
const currentAspect = w / h;
|
|
|
|
// Always fill entire window; use logical coords for game
|
|
this.logicalWidth = w;
|
|
this.logicalHeight = h;
|
|
|
|
// Enforce minimum 4:3: if narrower than 4:3, treat as 4:3 height
|
|
// (window fills screen; minimum 4:3 means we never let height dominate width)
|
|
if (currentAspect < minAspect) {
|
|
// Too tall — game uses 4:3 minimum: constrain logical height
|
|
this.logicalHeight = Math.floor(w / minAspect);
|
|
}
|
|
|
|
// Step 1: Set CSS size to logical dimensions
|
|
this.canvas.style.width = this.logicalWidth + 'px';
|
|
this.canvas.style.height = this.logicalHeight + 'px';
|
|
|
|
// Step 2: Scale bitmap to device pixel ratio
|
|
this.canvas.width = Math.floor(this.logicalWidth * dpr);
|
|
this.canvas.height = Math.floor(this.logicalHeight * dpr);
|
|
|
|
// Step 3: Scale context so all draw calls use logical pixels
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.ctx.scale(dpr, dpr);
|
|
|
|
// Notify Physics of new dimensions (Physics may not exist yet)
|
|
if (typeof Physics !== 'undefined' && Physics.onResize) {
|
|
Physics.onResize(this.logicalWidth, this.logicalHeight);
|
|
}
|
|
},
|
|
|
|
clear() {
|
|
this.ctx.clearRect(0, 0, this.logicalWidth, this.logicalHeight);
|
|
this.ctx.fillStyle = '#000';
|
|
this.ctx.fillRect(0, 0, this.logicalWidth, this.logicalHeight);
|
|
},
|
|
|
|
drawRect(x, y, w, h, color) {
|
|
this.ctx.fillStyle = color || '#fff';
|
|
this.ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
|
|
},
|
|
|
|
drawCircle(x, y, radius, color) {
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(Math.round(x), Math.round(y), radius, 0, Math.PI * 2);
|
|
this.ctx.fillStyle = color || '#fff';
|
|
this.ctx.fill();
|
|
}
|
|
};
|
|
|
|
const GameState = {
|
|
paddle1: {
|
|
x: 0, y: 0, // Set on Physics.init()
|
|
width: 12, height: 80,
|
|
speed: 400, // Logical pixels per second
|
|
color: '#fff'
|
|
},
|
|
ball: {
|
|
x: 0, y: 0, // Set on Physics.init()
|
|
radius: 8,
|
|
vx: 0, vy: 0, // Set on Physics.init()
|
|
speed: 0, // Current scalar speed
|
|
color: '#fff'
|
|
},
|
|
paddle2: {
|
|
x: 0, y: 0, // Set on Physics.init()
|
|
width: 12, height: 80,
|
|
speed: 400,
|
|
color: '#fff'
|
|
},
|
|
score1: 0,
|
|
score2: 0,
|
|
mode: null, // null = mode select screen; 'ai' = Solo vs AI; '2p' = 2-Player local
|
|
difficulty: 'medium', // 'easy', 'medium', 'hard'
|
|
gameState: 'title', // 'title', 'settings', 'modeSelect', 'diffSelect', 'playing', 'scored', 'gameover'
|
|
winner: null, // null, 'player1', 'player2', 'ai'
|
|
activePalette: null, // Set after GameConfig defined — see init sequence
|
|
soundEnabled: true,
|
|
selectedMenu: 0,
|
|
previousState: null
|
|
};
|
|
|
|
GameState.activePalette = GameConfig.PALETTES.MAGENTA;
|
|
|
|
const Input = {
|
|
keys: { w: false, s: false, arrowUp: false, arrowDown: false },
|
|
_handleKeyDown: null,
|
|
_handleKeyUp: null,
|
|
|
|
init() {
|
|
this._handleKeyDown = (e) => {
|
|
if (e.code === 'KeyW') { this.keys.w = true; e.preventDefault(); }
|
|
if (e.code === 'KeyS') { this.keys.s = true; e.preventDefault(); }
|
|
if (e.code === 'ArrowUp') { this.keys.arrowUp = true; e.preventDefault(); }
|
|
if (e.code === 'ArrowDown') { this.keys.arrowDown = true; e.preventDefault(); }
|
|
|
|
// Mode selection (only when in modeSelect state)
|
|
if (e.code === 'Digit1' && GameState.gameState === 'modeSelect') {
|
|
GameState.mode = 'ai';
|
|
GameState.gameState = 'diffSelect';
|
|
}
|
|
if (e.code === 'Digit2' && GameState.gameState === 'modeSelect') {
|
|
GameState.mode = '2p';
|
|
// Skip difficulty selection for 2-player — go straight to playing
|
|
GameState.gameState = 'playing';
|
|
Physics.serveBall();
|
|
}
|
|
|
|
// Difficulty selection (only when in diffSelect state)
|
|
if (e.code === 'Digit1' && GameState.gameState === 'diffSelect') {
|
|
GameState.difficulty = 'easy';
|
|
AI.init();
|
|
GameState.gameState = 'playing';
|
|
Physics.serveBall();
|
|
}
|
|
if (e.code === 'Digit2' && GameState.gameState === 'diffSelect') {
|
|
GameState.difficulty = 'medium';
|
|
AI.init();
|
|
GameState.gameState = 'playing';
|
|
Physics.serveBall();
|
|
}
|
|
if (e.code === 'Digit3' && GameState.gameState === 'diffSelect') {
|
|
GameState.difficulty = 'hard';
|
|
AI.init();
|
|
GameState.gameState = 'playing';
|
|
Physics.serveBall();
|
|
}
|
|
|
|
// Restart (only when gameover)
|
|
if (e.code === 'KeyR' && GameState.gameState === 'gameover') {
|
|
GameState.score1 = 0;
|
|
GameState.score2 = 0;
|
|
GameState.winner = null;
|
|
GameState.mode = null;
|
|
GameState.difficulty = 'medium';
|
|
GameState.gameState = 'modeSelect';
|
|
AI.init();
|
|
Physics.init(Renderer.logicalWidth, Renderer.logicalHeight);
|
|
}
|
|
};
|
|
this._handleKeyUp = (e) => {
|
|
if (e.code === 'KeyW') this.keys.w = false;
|
|
if (e.code === 'KeyS') this.keys.s = false;
|
|
if (e.code === 'ArrowUp') this.keys.arrowUp = false;
|
|
if (e.code === 'ArrowDown') this.keys.arrowDown = false;
|
|
};
|
|
document.addEventListener('keydown', this._handleKeyDown);
|
|
document.addEventListener('keyup', this._handleKeyUp);
|
|
},
|
|
|
|
cleanup() {
|
|
document.removeEventListener('keydown', this._handleKeyDown);
|
|
document.removeEventListener('keyup', this._handleKeyUp);
|
|
},
|
|
|
|
getVerticalInput() { return this.keys.w ? -1 : this.keys.s ? 1 : 0; },
|
|
getVerticalInput2() { return this.keys.arrowUp ? -1 : this.keys.arrowDown ? 1 : 0; }
|
|
};
|
|
|
|
const Physics = {
|
|
width: 0,
|
|
height: 0,
|
|
|
|
init(width, height) {
|
|
this.width = width;
|
|
this.height = height;
|
|
// Position paddle1 on left side, vertically centered
|
|
GameState.paddle1.x = 30;
|
|
GameState.paddle1.y = height / 2 - GameState.paddle1.height / 2;
|
|
GameState.paddle1.speed = GameConfig.paddleSpeed;
|
|
// Position paddle2 on right side, vertically centered
|
|
GameState.paddle2.x = width - 30 - GameState.paddle2.width;
|
|
GameState.paddle2.y = height / 2 - GameState.paddle2.height / 2;
|
|
GameState.paddle2.speed = GameConfig.paddleSpeed;
|
|
// Serve ball from center
|
|
this.serveBall();
|
|
},
|
|
|
|
onResize(width, height) {
|
|
this.init(width, height);
|
|
},
|
|
|
|
serveBall() {
|
|
const ball = GameState.ball;
|
|
ball.x = this.width / 2;
|
|
ball.y = this.height / 2;
|
|
ball.speed = GameConfig.initialBallSpeed;
|
|
|
|
// Random vertical angle between -45 and +45 degrees, always heading right
|
|
const angle = (Math.random() * 90 - 45) * Math.PI / 180;
|
|
// Randomly serve left or right
|
|
const dir = Math.random() < 0.5 ? 1 : -1;
|
|
ball.vx = Math.cos(angle) * ball.speed * dir;
|
|
ball.vy = Math.sin(angle) * ball.speed;
|
|
},
|
|
|
|
update(deltaTime) {
|
|
const ball = GameState.ball;
|
|
const paddle = GameState.paddle1;
|
|
|
|
// --- Move paddle1 ---
|
|
const dir = Input.getVerticalInput();
|
|
paddle.y += dir * paddle.speed * deltaTime;
|
|
paddle.y = Math.max(0, Math.min(this.height - paddle.height, paddle.y));
|
|
|
|
// --- Move paddle2 (human or AI) ---
|
|
const paddle2 = GameState.paddle2;
|
|
if (GameState.mode === '2p') {
|
|
const dir2 = Input.getVerticalInput2();
|
|
paddle2.y += dir2 * paddle2.speed * deltaTime;
|
|
paddle2.y = Math.max(0, Math.min(this.height - paddle2.height, paddle2.y));
|
|
} else if (GameState.mode === 'ai') {
|
|
AI.update(deltaTime);
|
|
}
|
|
|
|
// --- Move ball ---
|
|
ball.x += ball.vx * deltaTime;
|
|
ball.y += ball.vy * deltaTime;
|
|
|
|
// --- Wall bounce (top and bottom) ---
|
|
if (ball.y - ball.radius < 0) {
|
|
ball.y = ball.radius;
|
|
ball.vy = Math.abs(ball.vy);
|
|
}
|
|
if (ball.y + ball.radius > this.height) {
|
|
ball.y = this.height - ball.radius;
|
|
ball.vy = -Math.abs(ball.vy);
|
|
}
|
|
|
|
// --- Paddle collision (only check when ball moving left toward paddle1) ---
|
|
if (ball.vx < 0) {
|
|
this._checkPaddleCollision(ball, paddle);
|
|
}
|
|
|
|
// --- Paddle2 collision (only when ball moving right toward paddle2) ---
|
|
if (ball.vx > 0) {
|
|
this._checkPaddle2Collision(ball, GameState.paddle2);
|
|
}
|
|
},
|
|
|
|
_checkPaddleCollision(ball, paddle) {
|
|
// Speed is uncapped per CONTEXT.md. Tunneling risk begins when ball travels
|
|
// more than paddle.height px/frame. At 60fps, that's paddle.height * 60 px/s.
|
|
// With height=80: tunneling risk above ~4800 px/s. Current rallies max ~800px/s.
|
|
// If Phase 5 introduces higher speeds, add substep physics here.
|
|
|
|
// AABB: check ball center against paddle bounds (with ball radius buffer)
|
|
const inX = ball.x - ball.radius < paddle.x + paddle.width &&
|
|
ball.x + ball.radius > paddle.x;
|
|
const inY = ball.y + ball.radius > paddle.y &&
|
|
ball.y - ball.radius < paddle.y + paddle.height;
|
|
|
|
if (!inX || !inY) return;
|
|
|
|
// Zone-based deflection: divide paddle into 5 zones
|
|
const relativeHitPos = (ball.y - paddle.y) / paddle.height;
|
|
const hitZone = Math.max(0, Math.min(4, Math.floor(relativeHitPos * 5)));
|
|
|
|
// Map zone to angle (degrees) — positive = downward
|
|
const anglesDeg = [
|
|
-60, // Zone 0: top edge — steeply upward
|
|
-30, // Zone 1: upper — angled upward
|
|
5, // Zone 2: center — nearly flat (slight downward to avoid infinite horizontal)
|
|
30, // Zone 3: lower — angled downward
|
|
60 // Zone 4: bottom edge — steeply downward
|
|
];
|
|
|
|
const angleRad = anglesDeg[hitZone] * Math.PI / 180;
|
|
|
|
// Increment speed
|
|
ball.speed += GameConfig.speedIncrement;
|
|
|
|
// Decompose into velocity components, always going right after hitting paddle1
|
|
ball.vx = Math.cos(angleRad) * ball.speed;
|
|
ball.vy = Math.sin(angleRad) * ball.speed;
|
|
|
|
// Push ball out of paddle to prevent double-collision next frame
|
|
ball.x = paddle.x + paddle.width + ball.radius + 1;
|
|
},
|
|
|
|
_checkPaddle2Collision(ball, paddle) {
|
|
const inX = ball.x + ball.radius > paddle.x &&
|
|
ball.x - ball.radius < paddle.x + paddle.width;
|
|
const inY = ball.y + ball.radius > paddle.y &&
|
|
ball.y - ball.radius < paddle.y + paddle.height;
|
|
|
|
if (!inX || !inY) return;
|
|
|
|
const relativeHitPos = (ball.y - paddle.y) / paddle.height;
|
|
const hitZone = Math.max(0, Math.min(4, Math.floor(relativeHitPos * 5)));
|
|
const anglesDeg = [-60, -30, 5, 30, 60];
|
|
const angleRad = anglesDeg[hitZone] * Math.PI / 180;
|
|
|
|
ball.speed += GameConfig.speedIncrement;
|
|
|
|
// Ball leaves paddle2 going LEFT (negative vx)
|
|
ball.vx = -Math.cos(angleRad) * ball.speed;
|
|
ball.vy = Math.sin(angleRad) * ball.speed;
|
|
|
|
// Push ball left of paddle2 to prevent double-collision
|
|
ball.x = paddle.x - ball.radius - 1;
|
|
}
|
|
};
|
|
|
|
const AI = {
|
|
reactionElapsed: 0,
|
|
|
|
init() {
|
|
this.reactionElapsed = 0;
|
|
},
|
|
|
|
update(deltaTime) {
|
|
const difficulty = GameState.difficulty;
|
|
const config = GameConfig['AI_' + difficulty.toUpperCase()];
|
|
const ball = GameState.ball;
|
|
const paddle2 = GameState.paddle2;
|
|
|
|
// Reset reaction timer when ball is moving away (toward player side)
|
|
if (ball.vx < 0) {
|
|
this.reactionElapsed = 0;
|
|
return; // Ball heading toward player — AI waits
|
|
}
|
|
|
|
this.reactionElapsed += deltaTime;
|
|
if (this.reactionElapsed < config.reactionDelay) {
|
|
return; // Still in reaction delay window
|
|
}
|
|
|
|
// Predict ball y-position when it reaches paddle2 x
|
|
const targetY = this._predictBallY(ball, paddle2.x);
|
|
|
|
// Add difficulty-based error margin (random jitter each frame)
|
|
const error = (Math.random() - 0.5) * config.errorMargin * 2;
|
|
const aimY = targetY + error;
|
|
|
|
// Move paddle toward aim Y (clamped to field)
|
|
const centerY = paddle2.y + paddle2.height / 2;
|
|
const diff = aimY - centerY;
|
|
|
|
if (Math.abs(diff) > 3) { // Deadzone prevents jitter
|
|
const dir = Math.sign(diff);
|
|
paddle2.y += dir * config.speed * deltaTime;
|
|
paddle2.y = Math.max(0, Math.min(Physics.height - paddle2.height, paddle2.y));
|
|
}
|
|
},
|
|
|
|
_predictBallY(ball, targetX) {
|
|
let x = ball.x, y = ball.y, vx = ball.vx, vy = ball.vy;
|
|
const maxSteps = 500;
|
|
let steps = 0;
|
|
|
|
while (steps < maxSteps) {
|
|
if (Math.abs(x - targetX) < 1) break;
|
|
const timeToTarget = (targetX - x) / vx;
|
|
const timeStep = Math.min(0.016, Math.abs(timeToTarget));
|
|
|
|
x += vx * timeStep;
|
|
y += vy * timeStep;
|
|
|
|
// Wall bounces during prediction
|
|
if (y - ball.radius < 0) {
|
|
y = ball.radius;
|
|
vy = Math.abs(vy);
|
|
} else if (y + ball.radius > Physics.height) {
|
|
y = Physics.height - ball.radius;
|
|
vy = -Math.abs(vy);
|
|
}
|
|
steps++;
|
|
}
|
|
|
|
return y;
|
|
}
|
|
};
|
|
|
|
const GameLoop = {
|
|
stopHandle: null,
|
|
lastTime: 0,
|
|
pauseTime: 0,
|
|
|
|
start() {
|
|
this.lastTime = performance.now();
|
|
this.stopHandle = window.requestAnimationFrame(this.main.bind(this));
|
|
},
|
|
|
|
stop() {
|
|
if (this.stopHandle) cancelAnimationFrame(this.stopHandle);
|
|
},
|
|
|
|
main(currentTime) {
|
|
this.stopHandle = window.requestAnimationFrame(this.main.bind(this));
|
|
const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.05);
|
|
this.lastTime = currentTime;
|
|
|
|
const gs = GameState;
|
|
const ball = gs.ball;
|
|
|
|
// --- Physics update (only during active play) ---
|
|
if (gs.gameState === 'playing') {
|
|
Physics.update(deltaTime);
|
|
|
|
// Score detection — AFTER Physics.update(), NOT inside Physics
|
|
if (ball.x + ball.radius < 0) {
|
|
// Ball exited left edge → Player 2 (or AI) scores
|
|
gs.score2++;
|
|
gs.gameState = 'scored';
|
|
this.pauseTime = currentTime;
|
|
} else if (ball.x - ball.radius > Physics.width) {
|
|
// Ball exited right edge → Player 1 scores
|
|
gs.score1++;
|
|
gs.gameState = 'scored';
|
|
this.pauseTime = currentTime;
|
|
}
|
|
|
|
// Match-end check immediately after scoring
|
|
if (gs.score1 >= GameConfig.WIN_SCORE) {
|
|
gs.gameState = 'gameover';
|
|
gs.winner = 'player1';
|
|
} else if (gs.score2 >= GameConfig.WIN_SCORE) {
|
|
gs.gameState = 'gameover';
|
|
gs.winner = gs.mode === 'ai' ? 'ai' : 'player2';
|
|
}
|
|
}
|
|
|
|
// --- Auto-serve after ~1s pause ---
|
|
if (gs.gameState === 'scored') {
|
|
const elapsed = (currentTime - this.pauseTime) / 1000;
|
|
if (elapsed >= 1.0) {
|
|
Physics.serveBall();
|
|
gs.gameState = 'playing';
|
|
}
|
|
}
|
|
|
|
// --- Render ---
|
|
Renderer.clear();
|
|
|
|
if (gs.gameState === 'modeSelect') {
|
|
// Mode selection prompt
|
|
Renderer.ctx.fillStyle = '#fff';
|
|
Renderer.ctx.font = 'bold 28px monospace';
|
|
Renderer.ctx.textAlign = 'center';
|
|
Renderer.ctx.fillText('SUPER PONG NEXT GEN', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 60);
|
|
Renderer.ctx.font = '20px monospace';
|
|
Renderer.ctx.fillText('Press 1 — Solo vs AI', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2);
|
|
Renderer.ctx.fillText('Press 2 — 2 Player Local', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 36);
|
|
Renderer.ctx.textAlign = 'left';
|
|
return;
|
|
}
|
|
|
|
if (gs.gameState === 'diffSelect') {
|
|
Renderer.ctx.fillStyle = '#fff';
|
|
Renderer.ctx.font = 'bold 24px monospace';
|
|
Renderer.ctx.textAlign = 'center';
|
|
Renderer.ctx.fillText('SELECT DIFFICULTY', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 50);
|
|
Renderer.ctx.font = '20px monospace';
|
|
Renderer.ctx.fillText('1 — Easy', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2);
|
|
Renderer.ctx.fillText('2 — Medium', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 36);
|
|
Renderer.ctx.fillText('3 — Hard', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 72);
|
|
Renderer.ctx.textAlign = 'left';
|
|
return;
|
|
}
|
|
|
|
// --- Gameplay rendering (playing, scored, gameover) ---
|
|
|
|
// Paddles
|
|
const p1 = gs.paddle1;
|
|
const p2 = gs.paddle2;
|
|
Renderer.drawRect(p1.x, p1.y, p1.width, p1.height, p1.color);
|
|
Renderer.drawRect(p2.x, p2.y, p2.width, p2.height, p2.color);
|
|
|
|
// Ball (stationary during scored pause)
|
|
if (gs.gameState !== 'scored') {
|
|
const b = gs.ball;
|
|
Renderer.drawCircle(b.x, b.y, b.radius, b.color);
|
|
}
|
|
|
|
// Center divider line (dashed)
|
|
Renderer.ctx.setLineDash([10, 14]);
|
|
Renderer.ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
|
Renderer.ctx.lineWidth = 2;
|
|
Renderer.ctx.beginPath();
|
|
Renderer.ctx.moveTo(Renderer.logicalWidth / 2, 0);
|
|
Renderer.ctx.lineTo(Renderer.logicalWidth / 2, Renderer.logicalHeight);
|
|
Renderer.ctx.stroke();
|
|
Renderer.ctx.setLineDash([]);
|
|
|
|
// Scores
|
|
Renderer.ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
|
Renderer.ctx.font = 'bold 48px monospace';
|
|
Renderer.ctx.textAlign = 'center';
|
|
Renderer.ctx.fillText(gs.score1, Renderer.logicalWidth / 4, 64);
|
|
Renderer.ctx.fillText(gs.score2, Renderer.logicalWidth * 3 / 4, 64);
|
|
Renderer.ctx.textAlign = 'left';
|
|
|
|
// Game over overlay
|
|
if (gs.gameState === 'gameover') {
|
|
const winnerName = gs.winner === 'player1' ? 'Player 1' :
|
|
gs.winner === 'ai' ? 'AI' : 'Player 2';
|
|
Renderer.ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
|
Renderer.ctx.fillRect(0, 0, Renderer.logicalWidth, Renderer.logicalHeight);
|
|
Renderer.ctx.fillStyle = '#fff';
|
|
Renderer.ctx.font = 'bold 36px monospace';
|
|
Renderer.ctx.textAlign = 'center';
|
|
Renderer.ctx.fillText(winnerName + ' Wins!', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 20);
|
|
Renderer.ctx.font = '20px monospace';
|
|
Renderer.ctx.fillText('Press R to play again', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 24);
|
|
Renderer.ctx.textAlign = 'left';
|
|
}
|
|
}
|
|
};
|
|
|
|
// Initialize all modules
|
|
Renderer.init();
|
|
Physics.init(Renderer.logicalWidth, Renderer.logicalHeight);
|
|
Input.init();
|
|
AI.init();
|
|
GameLoop.start();
|
|
</script>
|
|
</body>
|
|
</html>
|