Files
2026-05-10 10:45:02 +08:00

882 lines
27 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Hoops</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
width: 100%; height: 100%;
background: #fafaf7;
overflow: hidden;
overscroll-behavior: none;
user-select: none; -webkit-user-select: none;
touch-action: none;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
#stage {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
#wrap {
position: relative;
/* 取畫面上能擺進的最大正方形 */
width: min(100vw, 100vh);
height: min(100vw, 100vh);
}
canvas {
display: block;
width: 100%; height: 100%;
background: #fafaf7;
touch-action: none;
}
.hint {
position: absolute;
left: 50%; bottom: 8px;
transform: translateX(-50%);
font-size: 5px;
letter-spacing: 0.01em;
opacity: 0.45;
color: #555;
white-space: nowrap;
pointer-events: none;
}
</style>
</head>
<body>
<div id="stage">
<div id="wrap">
<canvas id="game"></canvas>
<div class="hint">by Wiwi Kuan, https://wiwi.blog</div>
</div>
</div>
<script>
// =============================================================================
// CONFIG — 跟原本完全一樣
// =============================================================================
const DEFAULT_CONFIG = {
GAME_DURATION: 60,
COOLDOWN: 0.5,
JUMP_DURATION: 0.5,
JUMP_HEIGHT_RATIO: 0.25,
PERFECT_WINDOW_T: 0.28,
PERFECT_TOLERANCE: 0.001,
MAX_DRIFT_T: 0.35,
GRAVITY_RATIO: 5,
BALL_RADIUS_RATIO: 0.03,
RESTITUTION_RIM: 0.65,
RESTITUTION_BOARD: 0.7,
RESTITUTION_BALL: 0.7,
AIR_DRAG: 0,
SHOT_TARGET_TIME: 0.75,
SHOT_DRIFT_VEL: 0.4,
SHOT_DRIFT_ANGLE: 0.6,
BALL_IMG_SRC: null,
PERFECT_JITTER_VEL: 0.0025,
PERFECT_JITTER_ANGLE: 0.003,
EULER_COMPENSATION: 0.012,
};
const COLORS = {
ink: '#1a1a1a',
paper: '#fafaf7',
ball: '#c2410c',
rim: '#991b1b',
net: '#999',
gray: '#ccc',
};
const PHASE = { TITLE: 'title', PLAYING: 'playing', OVER: 'over' };
const MODE = { TIMED: 'timed', PRACTICE: 'practice' };
// =============================================================================
// 遊戲主體 — 接收 canvas + config,回傳 cleanup function
// 注意:原本是用 parent.clientWidth 取尺寸,這裡改成 wrap 元素的尺寸(其實一樣)
// 並且讓畫布是「正方形 = min(視窗寬, 視窗高)」,跟原本邏輯一致
// =============================================================================
function initGame(canvas, userConfig) {
const CONFIG = Object.assign({}, DEFAULT_CONFIG, userConfig || {});
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1;
function resize() {
const parent = canvas.parentElement;
const parentW = parent ? parent.clientWidth : 600;
const parentH = parent ? parent.clientHeight : 600;
// 取較小邊作為正方形邊長
const px = Math.max(280, Math.floor(Math.min(parentW, parentH)));
DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
canvas.style.width = px + 'px';
canvas.style.height = px + 'px';
canvas.width = Math.floor(px * DPR);
canvas.height = Math.floor(px * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
W = px; H = px;
}
resize();
let ballImg = null;
if (CONFIG.BALL_IMG_SRC) {
const img = new Image();
img.onload = () => { ballImg = img; };
img.src = CONFIG.BALL_IMG_SRC;
}
// ===== Audio =====
let actx = null;
function audio() {
if (!actx) {
try { actx = new (window.AudioContext || window.webkitAudioContext)(); }
catch (e) { actx = null; }
}
return actx;
}
function blip({ freq = 440, dur = 0.08, type = 'sine', vol = 0.15, sweep = 0, delay = 0 } = {}) {
const a = audio(); if (!a) return;
const t0 = a.currentTime + delay;
const o = a.createOscillator();
const g = a.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t0);
if (sweep) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + sweep), t0 + dur);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + 0.005);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g).connect(a.destination);
o.start(t0); o.stop(t0 + dur + 0.02);
}
const SFX = {
jump: () => blip({ freq: 220, dur: 0.06, type: 'triangle', vol: 0.08, sweep: 60 }),
shoot: () => blip({ freq: 380, dur: 0.09, type: 'square', vol: 0.07, sweep: 200 }),
rim: () => blip({ freq: 900, dur: 0.05, type: 'square', vol: 0.06, sweep: -300 }),
board: () => blip({ freq: 140, dur: 0.07, type: 'sine', vol: 0.12, sweep: -40 }),
swish: () => {
blip({ freq: 660, dur: 0.08, type: 'sine', vol: 0.10 });
blip({ freq: 990, dur: 0.10, type: 'sine', vol: 0.08, delay: 0.04 });
blip({ freq: 1320, dur: 0.12, type: 'sine', vol: 0.06, delay: 0.09 });
},
score: () => {
blip({ freq: 523, dur: 0.10, type: 'triangle', vol: 0.10 });
blip({ freq: 784, dur: 0.14, type: 'triangle', vol: 0.10, delay: 0.08 });
},
end: () => {
blip({ freq: 220, dur: 0.18, type: 'sawtooth', vol: 0.08 });
blip({ freq: 165, dur: 0.22, type: 'sawtooth', vol: 0.08, delay: 0.12 });
},
};
function geom() {
const hoopX = W * 0.78;
const hoopW = W * 0.085;
const rimR = W * 0.008;
const armLength = W * 0.018;
return {
ballR: W * CONFIG.BALL_RADIUS_RATIO,
ballX: W * 0.18,
groundY: H * 0.72,
hoopX, hoopY: H * 0.42, hoopW, rimR, armLength,
boardX: hoopX + hoopW / 2 + rimR + armLength,
boardTop: H * 0.42 - H * 0.18,
boardBot: H * 0.42 + H * 0.04,
netDepth: H * 0.06,
};
}
const state = {
phase: PHASE.TITLE,
mode: MODE.TIMED,
timeLeft: CONFIG.GAME_DURATION,
score: 0,
attempts: 0,
lastScoreFlash: 0,
holding: false,
holdT: 0,
holdStartTime: 0,
jumpDone: false,
cooldown: 0,
ready: true,
balls: [],
netPhase: 0,
netImpulse: 0,
nextBallId: 1,
};
// 給想作弊的讀者
if (typeof window !== 'undefined') window.__hoops = { state, CONFIG };
let titleButtons = null;
let exitButton = null;
function startGame(mode) {
state.phase = PHASE.PLAYING;
state.mode = mode;
state.timeLeft = CONFIG.GAME_DURATION;
state.score = 0;
state.attempts = 0;
state.lastScoreFlash = 0;
state.holding = false;
state.holdT = 0;
state.holdStartTime = 0;
state.jumpDone = false;
state.cooldown = 0;
state.ready = true;
state.balls = [];
state.netImpulse = 0;
}
function backToTitle() {
state.phase = PHASE.TITLE;
state.holding = false;
state.jumpDone = false;
state.balls = [];
}
function pressDown(pointerPos) {
audio();
if (state.phase === PHASE.OVER) {
backToTitle();
return;
}
if (state.phase === PHASE.TITLE) {
if (pointerPos && titleButtons) {
const { x, y } = pointerPos;
const inBtn = (b) => x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h;
if (inBtn(titleButtons.timed)) { startGame(MODE.TIMED); return; }
if (inBtn(titleButtons.practice)) { startGame(MODE.PRACTICE); return; }
}
return;
}
if (state.mode === MODE.PRACTICE && pointerPos && exitButton) {
const { x, y } = pointerPos;
const b = exitButton;
if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) {
backToTitle();
return;
}
}
if (!state.ready || state.holding) return;
state.holding = true;
state.holdT = 0;
state.holdStartTime = performance.now();
state.jumpDone = false;
SFX.jump();
}
function pressUp() {
if (!state.holding) return;
state.holding = false;
if (state.jumpDone) return;
const realHoldT = (performance.now() - state.holdStartTime) / 1000;
releaseShot(Math.min(realHoldT, CONFIG.JUMP_DURATION));
}
// ===== Event handlers =====
// App 內不需要擔心捲頁鍵盤事件保留給有實體鍵盤的裝置藍牙鍵盤、Chromebook
const isEditableTarget = (target) => {
if (!target) return false;
const tag = target.tagName || '';
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
};
const onKeyDown = (e) => {
if (isEditableTarget(e.target)) return;
if (state.phase === PHASE.TITLE) {
if (e.code === 'Digit1' || e.code === 'Numpad1') {
e.preventDefault(); if (e.repeat) return;
audio(); startGame(MODE.TIMED); return;
}
if (e.code === 'Digit2' || e.code === 'Numpad2') {
e.preventDefault(); if (e.repeat) return;
audio(); startGame(MODE.PRACTICE); return;
}
if (e.code === 'Space') e.preventDefault();
return;
}
if (state.phase === PHASE.OVER) {
if (e.code === 'Space') {
e.preventDefault(); if (e.repeat) return;
audio(); backToTitle();
}
return;
}
if (state.mode === MODE.PRACTICE && e.code === 'Escape') {
e.preventDefault(); if (e.repeat) return;
backToTitle(); return;
}
if (e.code !== 'Space') return;
e.preventDefault();
if (e.repeat) return;
pressDown();
};
const onKeyUp = (e) => {
if (e.code !== 'Space') return;
if (isEditableTarget(e.target)) return;
e.preventDefault();
if (state.phase !== PHASE.PLAYING) return;
pressUp();
};
const getPointerPos = (e) => {
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const onPointerDown = (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault();
try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
pressDown(getPointerPos(e));
};
const onPointerUp = (e) => {
if (!state.holding) return;
e.preventDefault();
pressUp();
};
const onPointerCancel = () => {
if (state.holding) { state.holding = false; state.jumpDone = false; }
};
const onResize = () => resize();
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('resize', onResize);
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerCancel);
// ===== Game logic — 100% 跟原本一樣 =====
function releaseShot(holdT) {
const g = geom();
const tNorm = Math.min(1, holdT / CONFIG.JUMP_DURATION);
const offset = tNorm - CONFIG.PERFECT_WINDOW_T;
const absOff = Math.abs(offset);
const jumpY = jumpOffset(tNorm) * (W * CONFIG.JUMP_HEIGHT_RATIO);
const bx = g.ballX;
const by = g.groundY - jumpY;
const tx = g.hoopX;
const ty = g.hoopY;
const T = CONFIG.SHOT_TARGET_TIME;
const vx0 = (tx - bx) / T;
const G = W * CONFIG.GRAVITY_RATIO;
const vy0 = (ty - by - 0.5 * G * T * T) / T - G * CONFIG.EULER_COMPENSATION;
const driftMag = absOff < CONFIG.PERFECT_TOLERANCE
? 0
: Math.min(1, (absOff - CONFIG.PERFECT_TOLERANCE) / (CONFIG.MAX_DRIFT_T - CONFIG.PERFECT_TOLERANCE));
const sign = Math.sign(offset) || (Math.random() < 0.5 ? -1 : 1);
const rand = (Math.random() - 0.5) * 0.4;
const speed = Math.hypot(vx0, vy0);
const angle = Math.atan2(vy0, vx0);
const baseJitter = (Math.random() - 0.5) * 2;
const baseJitter2 = (Math.random() - 0.5) * 2;
const driftMagCurved = driftMag ** 0.4;
const driftSpeed = (sign * driftMag * CONFIG.SHOT_DRIFT_VEL) + rand * 1 * (driftMag ** 3);
const driftAngle = (sign * driftMag * CONFIG.SHOT_DRIFT_ANGLE) + rand * 0.4 * driftMagCurved;
const newSpeed = speed * (1 + baseJitter * CONFIG.PERFECT_JITTER_VEL) * (1 + driftSpeed);
const newAngle = angle + baseJitter2 * CONFIG.PERFECT_JITTER_ANGLE + driftAngle;
const vx = Math.cos(newAngle) * newSpeed;
const vy = Math.sin(newAngle) * newSpeed;
state.balls.push({
id: state.nextBallId++,
x: bx, y: by, vx, vy,
r: g.ballR,
spin: (Math.random() - 0.5) * 8,
rot: 0,
passedRimTopAt: null,
scored: false,
lastBoardHit: 0,
lastRimHit: 0,
bornAt: performance.now(),
});
state.attempts += 1;
state.cooldown = CONFIG.COOLDOWN;
state.ready = false;
SFX.shoot();
}
function jumpOffset(tNorm) {
if (tNorm <= 0 || tNorm >= 1) return 0;
return Math.sin(tNorm * Math.PI);
}
function step(dt) {
if (state.phase !== PHASE.PLAYING) return;
if (state.mode === MODE.TIMED) {
state.timeLeft -= dt;
if (state.timeLeft <= 0) {
state.timeLeft = 0;
state.phase = PHASE.OVER;
SFX.end();
}
}
if (state.holding) {
state.holdT += dt;
if (state.holdT >= CONFIG.JUMP_DURATION) {
state.holdT = CONFIG.JUMP_DURATION;
state.jumpDone = true;
}
}
if (state.cooldown > 0) {
state.cooldown -= dt;
if (state.cooldown <= 0) { state.cooldown = 0; state.ready = true; }
}
const g = geom();
for (const b of state.balls) {
b.prevX = b.x;
b.prevY = b.y;
b.vy += W * CONFIG.GRAVITY_RATIO * dt;
b.vx *= (1 - CONFIG.AIR_DRAG * dt * 60);
b.vy *= (1 - CONFIG.AIR_DRAG * dt * 60);
b.x += b.vx * dt;
b.y += b.vy * dt;
b.rot += b.spin * dt;
collideBackboard(b, g);
collideRim(b, g);
detectScore(b, g);
}
for (let i = 0; i < state.balls.length; i++) {
for (let j = i + 1; j < state.balls.length; j++) {
collideBalls(state.balls[i], state.balls[j]);
}
}
state.balls = state.balls.filter(b => {
const margin = b.r * 4;
return b.x > -margin && b.x < W + margin && b.y < H + margin;
});
state.netImpulse *= Math.exp(-dt * 4);
state.netPhase += dt * 14;
if (state.lastScoreFlash > 0) state.lastScoreFlash -= dt;
}
function collideBackboard(b, g) {
if (b.x + b.r < g.boardX) return;
if (b.x - b.r > g.boardX) return;
if (b.y < g.boardTop - b.r || b.y > g.boardBot + b.r) return;
const cy = Math.max(g.boardTop, Math.min(g.boardBot, b.y));
const dx = b.x - g.boardX;
const dy = b.y - cy;
const d = Math.hypot(dx, dy);
if (d > b.r) return;
if (d === 0) return;
const nx = dx / d, ny = dy / d;
const overlap = b.r - d;
b.x += nx * overlap;
b.y += ny * overlap;
const vn = b.vx * nx + b.vy * ny;
if (vn < 0) {
b.vx -= (1 + CONFIG.RESTITUTION_BOARD) * vn * nx;
b.vy -= (1 + CONFIG.RESTITUTION_BOARD) * vn * ny;
const now = performance.now();
if (now - b.lastBoardHit > 80) { SFX.board(); b.lastBoardHit = now; }
}
}
function collideRim(b, g) {
const front = { x: g.hoopX - g.hoopW / 2, y: g.hoopY };
const back = { x: g.hoopX + g.hoopW / 2, y: g.hoopY };
for (const p of [front, back]) {
const dx = b.x - p.x;
const dy = b.y - p.y;
const d = Math.hypot(dx, dy);
const minD = b.r + g.rimR;
if (d < minD && d > 0) {
const nx = dx / d, ny = dy / d;
const overlap = minD - d;
b.x += nx * overlap;
b.y += ny * overlap;
const vn = b.vx * nx + b.vy * ny;
if (vn < 0) {
b.vx -= (1 + CONFIG.RESTITUTION_RIM) * vn * nx;
b.vy -= (1 + CONFIG.RESTITUTION_RIM) * vn * ny;
const now = performance.now();
if (now - b.lastRimHit > 80) { SFX.rim(); b.lastRimHit = now; }
}
}
}
const armLeft = back.x;
const armRight = g.boardX;
if (b.x > armLeft - b.r && b.x < armRight + b.r) {
const cx = Math.max(armLeft, Math.min(armRight, b.x));
const dx = b.x - cx;
const dy = b.y - g.hoopY;
const d = Math.hypot(dx, dy);
if (d < b.r && d > 0) {
const nx = dx / d, ny = dy / d;
const overlap = b.r - d;
b.x += nx * overlap;
b.y += ny * overlap;
const vn = b.vx * nx + b.vy * ny;
if (vn < 0) {
b.vx -= (1 + CONFIG.RESTITUTION_RIM) * vn * nx;
b.vy -= (1 + CONFIG.RESTITUTION_RIM) * vn * ny;
const now = performance.now();
if (now - b.lastRimHit > 80) { SFX.rim(); b.lastRimHit = now; }
}
}
}
}
function detectScore(b, g) {
if (b.scored) return;
const rimY = g.hoopY;
const xL = g.hoopX - g.hoopW / 2 + g.rimR;
const xR = g.hoopX + g.hoopW / 2 - g.rimR;
if (b.passedRimTopAt === null && b.y < rimY && b.x > xL - b.r && b.x < xR + b.r) {
b.passedRimTopAt = b.y;
}
const prevY = b.prevY != null ? b.prevY : b.y;
if (
!b.scored &&
b.passedRimTopAt !== null &&
b.vy > 0 &&
prevY <= rimY &&
b.y > rimY
) {
if (b.x > xL && b.x < xR) {
b.scored = true;
state.score += 1;
state.lastScoreFlash = 0.5;
state.netImpulse = 1;
const now = performance.now();
const recentlyHit = (now - b.lastRimHit < 250) || (now - b.lastBoardHit < 250);
if (recentlyHit) SFX.score(); else SFX.swish();
}
}
}
function collideBalls(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const minD = a.r + b.r;
if (d >= minD || d === 0) return;
const nx = dx / d, ny = dy / d;
const overlap = minD - d;
a.x -= nx * overlap / 2;
a.y -= ny * overlap / 2;
b.x += nx * overlap / 2;
b.y += ny * overlap / 2;
const rvx = b.vx - a.vx, rvy = b.vy - a.vy;
const vn = rvx * nx + rvy * ny;
if (vn > 0) return;
const e = CONFIG.RESTITUTION_BALL;
const j = -(1 + e) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
}
// ===== Rendering =====
function draw() {
const g = geom();
ctx.clearRect(0, 0, W, H);
drawFrame();
drawScore();
drawBottomRight();
drawHoop(g);
drawShooterBall(g);
for (const b of state.balls) drawBall(b);
if (state.phase === PHASE.PLAYING) drawAccuracyBottomLeft();
if (state.phase === PHASE.TITLE) drawTitle();
else if (state.phase === PHASE.OVER) drawGameOver();
}
function drawFrame() {
ctx.save();
ctx.strokeStyle = COLORS.ink;
ctx.lineWidth = 2;
ctx.strokeRect(0.5, 0.5, W - 1, H - 1);
ctx.restore();
}
function drawScore() {
ctx.save();
ctx.fillStyle = COLORS.ink;
const flash = Math.max(0, state.lastScoreFlash);
const scale = 1 + flash * 0.4;
const fontSize = Math.floor(W * 0.085 * scale);
ctx.font = `700 ${fontSize}px ui-monospace, "SF Mono", Menlo, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(String(state.score), W / 2, H * 0.05);
ctx.restore();
}
function drawBottomRight() {
if (state.phase !== PHASE.PLAYING) { exitButton = null; return; }
ctx.save();
if (state.mode === MODE.TIMED) {
ctx.fillStyle = COLORS.ink;
const fontSize = Math.floor(W * 0.05);
ctx.font = `500 ${fontSize}px ui-monospace, "SF Mono", Menlo, monospace`;
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
const t = Math.max(0, state.timeLeft);
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
const txt = `${m}:${String(s).padStart(2, '0')}`;
ctx.fillText(txt, W - W * 0.05, H - H * 0.04);
exitButton = null;
} else {
// 練習模式:手機沒有 esc 鍵,所以改寫成「回主畫面」
const label = '回主畫面';
const fontSize = Math.floor(W * 0.026);
ctx.font = `500 ${fontSize}px ui-monospace, "SF Mono", Menlo, monospace`;
const padX = W * 0.018;
const padY = W * 0.012;
const textW = ctx.measureText(label).width;
const btnW = textW + padX * 2;
const btnH = fontSize + padY * 2;
const btnX = W - W * 0.05 - btnW;
const btnY = H * 0.04;
exitButton = { x: btnX, y: btnY, w: btnW, h: btnH };
ctx.lineWidth = Math.max(1, W * 0.002);
ctx.strokeStyle = COLORS.gray;
ctx.fillStyle = COLORS.paper;
ctx.beginPath();
ctx.rect(btnX, btnY, btnW, btnH);
ctx.fill(); ctx.stroke();
ctx.fillStyle = COLORS.gray;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, btnX + btnW / 2, btnY + btnH / 2);
}
ctx.restore();
}
function drawAccuracyBottomLeft() {
ctx.save();
ctx.fillStyle = COLORS.gray;
const fontSize = Math.floor(W * 0.035);
ctx.font = `500 ${fontSize}px ui-monospace, "SF Mono", Menlo, monospace`;
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
const pct = state.attempts > 0 ? Math.round((state.score / state.attempts) * 100) : 0;
const txt = `${state.score}/${state.attempts} (${pct}%)`;
ctx.fillText(txt, W * 0.05, H - H * 0.06);
ctx.restore();
}
function drawHoop(g) {
ctx.save();
ctx.strokeStyle = COLORS.ink;
ctx.lineWidth = Math.max(2, W * 0.005);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(g.boardX, g.boardTop);
ctx.lineTo(g.boardX, g.boardBot);
ctx.stroke();
ctx.strokeStyle = COLORS.ink;
ctx.lineWidth = Math.max(1, W * 0.003);
ctx.beginPath();
ctx.moveTo(g.hoopX + g.hoopW / 2, g.hoopY);
ctx.lineTo(g.boardX, g.hoopY);
ctx.stroke();
ctx.strokeStyle = COLORS.rim;
ctx.lineWidth = Math.max(2, W * 0.006);
ctx.beginPath();
ctx.moveTo(g.hoopX - g.hoopW / 2, g.hoopY);
ctx.lineTo(g.hoopX + g.hoopW / 2, g.hoopY);
ctx.stroke();
ctx.strokeStyle = COLORS.net;
ctx.lineWidth = Math.max(1, W * 0.0025);
const netLines = 7;
const baseY = g.hoopY;
const bottomY = g.hoopY + g.netDepth;
const leftX = g.hoopX - g.hoopW / 2;
const rightX = g.hoopX + g.hoopW / 2;
const wiggle = state.netImpulse * 4;
for (let i = 0; i <= netLines; i++) {
const t = i / netLines;
const topX = leftX + t * (rightX - leftX);
const bx = leftX + g.hoopW * 0.2 + t * (g.hoopW * 0.6);
const phaseOff = i * 0.7;
const dx = Math.sin(state.netPhase + phaseOff) * wiggle;
ctx.beginPath();
ctx.moveTo(topX, baseY);
ctx.lineTo(bx + dx, bottomY + Math.abs(dx) * 0.3);
ctx.stroke();
}
for (let k = 1; k <= 2; k++) {
const yy = baseY + (g.netDepth * k / 3);
ctx.beginPath();
const dx = Math.sin(state.netPhase + k) * wiggle * 0.6;
ctx.moveTo(leftX + g.hoopW * 0.08 * k, yy);
ctx.lineTo(rightX - g.hoopW * 0.08 * k + dx, yy);
ctx.stroke();
}
ctx.restore();
}
function drawShooterBall(g) {
if (state.phase !== PHASE.PLAYING) return;
if (!state.ready && !state.holding) return;
let yOff = 0;
if (state.holding) {
const tNorm = Math.min(1, state.holdT / CONFIG.JUMP_DURATION);
yOff = jumpOffset(tNorm) * (W * CONFIG.JUMP_HEIGHT_RATIO);
}
drawBallAt(g.ballX, g.groundY - yOff, g.ballR, 0);
}
function drawBall(b) { drawBallAt(b.x, b.y, b.r, b.rot); }
function drawBallAt(x, y, r, rot) {
ctx.save();
if (ballImg) {
ctx.translate(x, y);
ctx.rotate(rot);
ctx.drawImage(ballImg, -r, -r, r * 2, r * 2);
} else {
ctx.fillStyle = COLORS.ball;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = Math.max(1, r * 0.08);
ctx.beginPath();
ctx.arc(x, y, r * 0.75, rot, rot + Math.PI);
ctx.stroke();
}
ctx.restore();
}
function drawTitle() {
ctx.save();
ctx.fillStyle = 'rgba(250,250,247,0.85)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = COLORS.ink;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `700 ${Math.floor(W * 0.14)}px sans-serif`;
ctx.fillText('🏀🏀🏀', W / 2, H * 0.22);
const btnW = W * 0.7;
const btnH = H * 0.11;
const btnX = (W - btnW) / 2;
const btnGap = H * 0.025;
const btn1Y = H * 0.4;
const btn2Y = btn1Y + btnH + btnGap;
titleButtons = {
timed: { x: btnX, y: btn1Y, w: btnW, h: btnH },
practice: { x: btnX, y: btn2Y, w: btnW, h: btnH },
};
const drawBtn = (b, label, sub) => {
ctx.lineWidth = Math.max(1.5, W * 0.003);
ctx.strokeStyle = COLORS.ink;
ctx.fillStyle = COLORS.paper;
ctx.beginPath();
ctx.rect(b.x, b.y, b.w, b.h);
ctx.fill(); ctx.stroke();
ctx.fillStyle = COLORS.ink;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `600 ${Math.floor(W * 0.04)}px ui-monospace, "SF Mono", Menlo, monospace`;
ctx.fillText(label, b.x + b.w / 2, b.y + b.h / 2 - W * 0.012);
ctx.font = `400 ${Math.floor(W * 0.026)}px ui-monospace, "SF Mono", Menlo, monospace`;
ctx.fillText(sub, b.x + b.w / 2, b.y + b.h / 2 + W * 0.022);
};
drawBtn(titleButtons.timed, '1. 限時模式', `${CONFIG.GAME_DURATION} 秒內投進越多越好`);
drawBtn(titleButtons.practice, '2. 練習模式', '不限時,自由輕鬆投');
ctx.font = `400 ${Math.floor(W * 0.027)}px ui-monospace, "SF Mono", Menlo, monospace`;
ctx.fillStyle = 'rgba(26,26,26,0.55)';
ctx.fillText('點按鈕選擇模式', W / 2, H * 0.88);
ctx.restore();
}
function drawGameOver() {
ctx.save();
ctx.fillStyle = 'rgba(250,250,247,0.9)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = COLORS.ink;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `500 ${Math.floor(W * 0.045)}px ui-monospace, "SF Mono", Menlo, monospace`;
ctx.fillText('時間到', W / 2, H * 0.36);
ctx.font = `700 ${Math.floor(W * 0.22)}px ui-monospace, "SF Mono", Menlo, monospace`;
ctx.fillText(String(state.score), W / 2, H * 0.5);
ctx.font = `400 ${Math.floor(W * 0.03)}px ui-monospace, "SF Mono", Menlo, monospace`;
ctx.fillText('點任何地方再玩一次', W / 2, H * 0.66);
ctx.restore();
}
// ===== Loop =====
let last = performance.now();
let rafId = 0;
function frame(now) {
let dt = (now - last) / 1000;
last = now;
if (dt > 0.05) dt = 0.05;
step(dt);
draw();
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('resize', onResize);
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerCancel);
if (actx && actx.state !== 'closed') actx.close().catch(() => {});
};
}
// 啟動!
initGame(document.getElementById('game'), {});
</script>
</body>
</html>