Initial commit
This commit is contained in:
881
app/build/intermediates/assets/debug/mergeDebugAssets/index.html
Normal file
881
app/build/intermediates/assets/debug/mergeDebugAssets/index.html
Normal file
@@ -0,0 +1,881 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user