Files
Hoops/Hoops3.jsx

1041 lines
32 KiB
React
Raw Normal View History

2026-05-06 02:20:52 +00:00
import React, { useEffect, useRef } from 'react';
import BrowserOnly from '@docusaurus/BrowserOnly';
// =============================================================================
// CONFIG — 預設值,可以從 props 覆蓋
// =============================================================================
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,
2026-05-06 09:35:35 +00:00
RESTITUTION_RIM: 0.65,
RESTITUTION_BOARD: 0.7,
2026-05-06 02:20:52 +00:00
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,
2026-05-06 09:35:35 +00:00
PERFECT_JITTER_VEL: 0.0025, // perfect 時速度的隨機浮動範圍±1.5%
PERFECT_JITTER_ANGLE: 0.003, // perfect 時角度的隨機浮動±0.012 弧度,約 0.7 度)
EULER_COMPENSATION: 0.012, // semi-implicit Euler 的初幀重力補償(秒)。偏短就調大、偏長就調小。
2026-05-06 02:20:52 +00:00
};
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',
};
// =============================================================================
// Inline styles — 不需要額外 CSS 檔
// =============================================================================
const styles = {
stage: {
display: 'block',
width: '100%',
padding: '1rem 0 2rem',
touchAction: 'none',
userSelect: 'none',
WebkitTapHighlightColor: 'transparent',
},
wrap: {
position: 'relative',
width: '100%',
margin: '0 auto',
},
canvas: {
display: 'block',
background: COLORS.paper,
cursor: 'pointer',
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
width: '100%',
height: 'auto',
touchAction: 'none', // 確保 pointer events 能完整接管,瀏覽器不會搶去做手勢
},
hint: {
position: 'absolute',
left: '50%',
bottom: '-24px',
transform: 'translateX(-50%)',
fontSize: '11px',
letterSpacing: '0.2em',
textTransform: 'uppercase',
opacity: 0.45,
whiteSpace: 'nowrap',
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
color: COLORS.ink,
},
};
// =============================================================================
// 遊戲主體 — 接收 canvas + config,回傳 cleanup function
// =============================================================================
function initGame(canvas, userConfig) {
const CONFIG = { ...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 px = Math.max(280, Math.floor(parentW));
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();
// Ball image (optional)
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; // 連接臂的長度rim 後緣到籃板的距離)
return {
ballR: W * CONFIG.BALL_RADIUS_RATIO,
ballX: W * 0.18,
groundY: H * 0.72,
hoopX: hoopX,
hoopY: H * 0.42,
hoopW: hoopW,
rimR: rimR,
armLength: armLength,
// 籃板往右推,騰出空間讓 rim 後緣有「後面」可以彈
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 };
}
// ===== 標題畫面按鈕區域(給 pointer 用) =====
// 在 drawTitle 計算過後存起來pressDown 時用來判斷點到哪個按鈕
let titleButtons = null; // { timed: {x, y, w, h}, practice: {x, y, w, h} }
// 練習模式右下角的「回主畫面」按鈕
let exitButton = null; // { x, y, w, h }
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;
}
// 標題畫面:用 pointer 位置判斷點到哪個按鈕;沒有 pointerPos鍵盤就忽略
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 =====
// 鍵盤:留在 window(canvas 拿不到鍵盤焦點)。
// 關鍵:只要是空白鍵且不在輸入框,就先無條件 preventDefault,
// 否則 cooldown 中或按住不放(repeat)時,瀏覽器會拿去捲動頁面。
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 (!inViewport) return;
// 標題畫面1 / 2 選模式
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;
}
// 標題畫面下空白鍵不做事,但仍 preventDefault 避免捲動
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;
}
// 遊戲中:練習模式可用 Esc 直接回主畫面
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;
if (!inViewport) return;
e.preventDefault();
if (state.phase !== PHASE.PLAYING) return;
pressUp();
};
// Pointer events:用 setPointerCapture 把後續事件鎖在 canvas 上,
// 滑出邊界放開也還是會送回 canvas,window 完全不需要監聽,
// 也就不會干擾頁面其他連結的點擊(這就是 Android 連結失效的原因)。
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();
// ===== Viewport 偵測:canvas 不在畫面上時,就不要攔截空白鍵 =====
// 否則玩家捲到文章下方想用空白鍵翻頁時,會被遊戲吃掉。
let inViewport = true; // 預設視為在畫面內,IntersectionObserver 第一次回呼會修正
let io = null;
if (typeof IntersectionObserver !== 'undefined') {
io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
inViewport = entry.isIntersecting;
// 離開畫面時,如果正在蓄力就靜默結束(避免捲回來時球自己飛出去)
if (!inViewport && state.holding) {
state.holding = false;
state.jumpDone = false;
}
}
},
{ threshold: 0 } // 只要有一個像素在畫面內就算
);
io.observe(canvas);
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('resize', onResize);
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerCancel);
function releaseShot(holdT) {
const g = geom();
const tNorm = Math.min(1, holdT / CONFIG.JUMP_DURATION);
const offset = tNorm - CONFIG.PERFECT_WINDOW_T;
2026-05-06 09:35:35 +00:00
// const offset = -0.008; // 測試用
2026-05-06 02:20:52 +00:00
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);
// 基礎 jitter — 所有球都會有,讓每次投籃略微不同
const baseJitter = (Math.random() - 0.5) * 2; // -1 ~ 1
const baseJitter2 = (Math.random() - 0.5) * 2;
2026-05-06 09:35:35 +00:00
// 曲線的 driftMag
const driftMagCurved = driftMag ** 0.4;
2026-05-06 02:20:52 +00:00
2026-05-06 09:35:35 +00:00
// drift — 釋放時機不準時的額外偏移
const driftSpeed = (sign * driftMag * CONFIG.SHOT_DRIFT_VEL) + rand * 1 * (driftMag ** 3);
const driftAngle = (sign * driftMag * CONFIG.SHOT_DRIFT_ANGLE) + rand * 0.4 * driftMagCurved;
// console.log(driftMag, driftSpeed, driftAngle);
// console.log 顯示投籃資訊
console.log(`[shot] 按了 ${(tNorm * CONFIG.JUMP_DURATION * 1000).toFixed(0)}ms ${absOff < CONFIG.PERFECT_TOLERANCE ? '✨ PERFECT' : offset < 0 ? `⏪ 太早 ${Math.abs(offset * CONFIG.JUMP_DURATION * 1000).toFixed(0)}ms` : `⏩ 太晚 ${(offset * CONFIG.JUMP_DURATION * 1000).toFixed(0)}ms`} | 偏移力度 ${(driftSpeed + baseJitter * CONFIG.PERFECT_JITTER_VEL).toFixed(4)} 偏移角度 ${(driftAngle + baseJitter2 * CONFIG.PERFECT_JITTER_ANGLE).toFixed(4)}`);
2026-05-06 02:20:52 +00:00
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 };
// 兩個 rim 端點的圓形碰撞
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;
}
}
}
}
// 連接臂:從 rim 後緣 (back.x, back.y) 到籃板 (boardX, hoopY) 的水平線段
// 球從上方或下方接近時要反彈
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);
// 連接臂視為很細的線(半徑近 0所以最小距離就是 b.r
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;
}
// 階段二:用實際的前一幀位置判斷下穿,不再用固定 0.016 回推
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();
}
// 右下角:限時模式顯示倒數,練習模式顯示「回主畫面 (esc)」按鈕
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 = '回主畫面 (esc)';
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.04);
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();
// 連接臂rim 後緣到籃板(用 ink 色,視覺上跟籃板統一)
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();
// 籃框rim— 比連接臂粗、用 rim 色
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';
// 標題 emoji
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('鍵盤 1 / 2或點按鈕', 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);
// ===== Cleanup =====
return () => {
cancelAnimationFrame(rafId);
if (io) io.disconnect();
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(() => {});
}
};
}
// =============================================================================
// React component
// =============================================================================
function HoopsInner({ config }) {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const cleanup = initGame(canvas, config || {});
return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div style={styles.stage}>
<div style={styles.wrap}>
<canvas ref={canvasRef} style={styles.canvas} />
<div style={styles.hint}>投籃按鍵後放開</div>
</div>
</div>
);
}
export default function Hoops(props) {
return (
<BrowserOnly fallback={<div style={{ height: 400 }} />}>
{() => <HoopsInner {...props} />}
</BrowserOnly>
);
}