1034 lines
32 KiB
JavaScript
1034 lines
32 KiB
JavaScript
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,
|
||
RESTITUTION_RIM: 0.75,
|
||
RESTITUTION_BOARD: 0.75,
|
||
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.002, // perfect 時速度的隨機浮動範圍(±1.5%)
|
||
PERFECT_JITTER_ANGLE: 0.0015, // perfect 時角度的隨機浮動(±0.012 弧度,約 0.7 度)
|
||
EULER_COMPENSATION: 0.011, // semi-implicit Euler 的初幀重力補償(秒)。偏短就調大、偏長就調小。
|
||
};
|
||
|
||
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;
|
||
const absOff = Math.abs(offset);
|
||
|
||
// console.log 顯示投籃資訊
|
||
console.log(`[shot] 按了 ${(tNorm * CONFIG.JUMP_DURATION * 1000).toFixed(0)}ms / 完美時機 ${(CONFIG.PERFECT_WINDOW_T * 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`}`);
|
||
|
||
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;
|
||
|
||
// drift — 釋放時機不準時的額外偏移
|
||
const driftSpeed = (sign * driftMag * CONFIG.SHOT_DRIFT_VEL) + rand * 0.05 * driftMag;
|
||
const driftAngle = (sign * driftMag * CONFIG.SHOT_DRIFT_ANGLE) + rand * 0.15 * driftMag;
|
||
|
||
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>
|
||
);
|
||
}
|