Files
2026-08-27 22:54:14 +08:00

742 lines
28 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-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>合成交大 · Integrate SJTU</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
background: radial-gradient(1200px 800px at 50% -10%, #eef4ff 0%, #d9e7fb 45%, #e2d7f6 100%);
display: flex; flex-direction: column; align-items: center; justify-content: center;
touch-action: none; user-select: none; -webkit-user-select: none;
}
#gameWrap {
position: relative;
width: min(92vw, 430px);
aspect-ratio: 360 / 640;
border-radius: 16px;
box-shadow: 0 14px 44px rgba(26, 60, 120, 0.38);
overflow: hidden;
background: #f3f1ec;
}
canvas {
display: block; width: 100%; height: 100%;
touch-action: none; cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.hint {
margin-top: 10px; font-size: 13px; color: rgba(30, 60, 120, 0.75);
letter-spacing: 1px; text-align: center;
}
#overlay {
position: fixed; inset: 0; z-index: 20;
display: flex; align-items: center; justify-content: center;
background: rgba(12, 22, 50, 0.55); backdrop-filter: blur(3px);
opacity: 1; transition: opacity .25s ease;
}
#overlay.hidden { opacity: 0; pointer-events: none; }
.panel {
background: linear-gradient(160deg, #ffffff, #edf3ff);
border-radius: 18px; padding: 26px 40px; text-align: center;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
}
.panel h2 { font-size: 26px; color: #1a3c6d; margin-bottom: 6px; letter-spacing: 2px; }
.panel .sub { color: #667; font-size: 13px; margin-bottom: 12px; }
.panel .score { font-size: 42px; font-weight: 800; color: #e0562b; margin-bottom: 20px; }
#restartBtn {
font: bold 17px "Microsoft YaHei", "PingFang SC", sans-serif;
color: #fff; background: linear-gradient(135deg, #2b6bd8, #1a3c6d);
border: none; border-radius: 30px; padding: 12px 46px; cursor: pointer;
box-shadow: 0 6px 18px rgba(26, 60, 120, 0.4);
transition: transform .12s ease;
}
#restartBtn:active { transform: scale(0.95); }
</style>
</head>
<body>
<div id="gameWrap">
<canvas id="game" width="360" height="640"></canvas>
</div>
<div class="hint">按住左右移动 · 松手投掷(480ms 冷却)</div>
<div id="overlay" class="hidden">
<div class="panel">
<h2>游戏结束</h2>
<div class="sub">堆叠高度超过了红线</div>
<div class="score" id="finalScore">0</div>
<button id="restartBtn">重新开始</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script>
<script>
(function () {
'use strict';
if (typeof Matter === 'undefined') {
document.body.innerHTML = '<p style="padding:40px;text-align:center;font-size:15px;">加载 Matter.js 失败,请检查网络后刷新页面。</p>';
throw new Error('Matter.js not loaded');
}
// ============ SVG 校徽内联字符串 ============
const SVG_STRINGS = [
`<svg xmlns="http://www.w3.org/2000/svg" width="70" height="70" viewBox="0 0 70 70">
<circle cx="35" cy="35" r="25" fill="#1A3C6D"/>
<circle cx="35" cy="35" r="22" fill="none" stroke="#FFFFFF" stroke-opacity="0.85" stroke-width="1.5"/>
<ellipse cx="26.2" cy="25" rx="8.8" ry="5" fill="#FFFFFF" opacity="0.16"/>
</svg>
`,
`<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="30" fill="#9B1B30"/>
<circle cx="40" cy="40" r="27" fill="none" stroke="#FFFFFF" stroke-opacity="0.85" stroke-width="1.5"/>
<ellipse cx="29.5" cy="28" rx="10.5" ry="6" fill="#FFFFFF" opacity="0.16"/>
</svg>
`,
`<svg xmlns="http://www.w3.org/2000/svg" width="90" height="90" viewBox="0 0 90 90">
<circle cx="45" cy="45" r="35" fill="#005BAC"/>
<circle cx="45" cy="45" r="32" fill="none" stroke="#FFFFFF" stroke-opacity="0.85" stroke-width="1.5"/>
<ellipse cx="32.8" cy="31" rx="12.2" ry="7" fill="#FFFFFF" opacity="0.16"/>
</svg>
`,
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r="38" fill="#3A6EA5"/>
<circle cx="48" cy="48" r="35" fill="none" stroke="#FFFFFF" stroke-opacity="0.85" stroke-width="1.5"/>
<ellipse cx="34.7" cy="32.8" rx="13.3" ry="7.6" fill="#FFFFFF" opacity="0.16"/>
</svg>
`,
`<svg xmlns="http://www.w3.org/2000/svg" width="104" height="104" viewBox="0 0 104 104">
<circle cx="52" cy="52" r="42" fill="#003366"/>
<circle cx="52" cy="52" r="39" fill="none" stroke="#FFFFFF" stroke-opacity="0.85" stroke-width="1.5"/>
<ellipse cx="37.3" cy="35.2" rx="14.7" ry="8.4" fill="#FFFFFF" opacity="0.16"/>
</svg>
`,
`<svg xmlns="http://www.w3.org/2000/svg" width="112" height="112" viewBox="0 0 112 112">
<circle cx="56" cy="56" r="46" fill="#A6093D"/>
<circle cx="56" cy="56" r="43" fill="none" stroke="#FFFFFF" stroke-opacity="0.85" stroke-width="1.5"/>
<ellipse cx="39.9" cy="37.6" rx="16.1" ry="9.2" fill="#FFFFFF" opacity="0.16"/>
</svg>
`,
`<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="50" fill="#8B0000"/>
<circle cx="60" cy="60" r="47" fill="none" stroke="#FFFFFF" stroke-opacity="0.85" stroke-width="1.5"/>
<ellipse cx="42.5" cy="40" rx="17.5" ry="10" fill="#FFFFFF" opacity="0.16"/>
</svg>
`,
`<svg xmlns="http://www.w3.org/2000/svg" width="130" height="130" viewBox="0 0 130 130">
<circle cx="65" cy="65" r="55" fill="#5C3317"/>
<circle cx="65" cy="65" r="52" fill="none" stroke="#FFFFFF" stroke-opacity="0.85" stroke-width="1.5"/>
<ellipse cx="45.8" cy="43" rx="19.2" ry="11" fill="#FFFFFF" opacity="0.16"/>
</svg>
`,
`<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140" viewBox="0 0 140 140">
<circle cx="70" cy="70" r="60" fill="#D52B1E"/>
<circle cx="70" cy="70" r="57" fill="none" stroke="#FFFFFF" stroke-opacity="0.85" stroke-width="1.5"/>
<ellipse cx="49" cy="46" rx="21" ry="12" fill="#FFFFFF" opacity="0.16"/>
</svg>
`,
];
const BADGE_CACHE = new Array(9);
function preloadBadges() {
for (let i = 0; i < SVG_STRINGS.length; i++) {
const img = new Image();
img.onload = (function (idx, im) {
return function () {
const info = LEVELS[idx];
const cvs = document.createElement('canvas');
cvs.width = info.radius * 2 + 4;
cvs.height = info.radius * 2 + 4;
const cx = cvs.getContext('2d');
const s = cvs.width;
cx.save();
cx.beginPath();
cx.arc(s / 2, s / 2, info.radius, 0, Math.PI * 2);
cx.clip();
cx.drawImage(im, 0, 0, s, s);
cx.restore();
BADGE_CACHE[idx] = cvs;
};
})(i, img);
img.onerror = function () {};
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(SVG_STRINGS[i])));
}
}
// ============ 等级数据(半径 px、名称、特征色) ============
const LEVELS = [
{ name: '西安交通大学', short: '西交', radius: 25, color: '#1A3C6D' },
{ name: '哈尔滨工业大学', short: '哈工大', radius: 30, color: '#9B1B30' },
{ name: '中国科学技术大学', short: '中科大', radius: 35, color: '#005BAC' },
{ name: '南京大学', short: '南大', radius: 38, color: '#3A6EA5' },
{ name: '浙江大学', short: '浙大', radius: 42, color: '#003366' },
{ name: '复旦大学', short: '复旦', radius: 46, color: '#A6093D' },
{ name: '北京大学', short: '北大', radius: 50, color: '#8B0000' },
{ name: '清华大学', short: '清华', radius: 55, color: '#5C3317' },
{ name: '上海交通大学', short: '上交', radius: 60, color: '#D52B1E' }
];
// ============ 刷新权重表(解锁 N 级后取 [1..N]) ============
const WEIGHTS = [
[1.0],
[0.65, 0.35],
[0.50, 0.35, 0.15],
[0.40, 0.30, 0.20, 0.10],
[0.35, 0.25, 0.20, 0.12, 0.08],
[0.30, 0.22, 0.18, 0.14, 0.10, 0.06],
[0.25, 0.20, 0.17, 0.14, 0.11, 0.08, 0.05],
[0.22, 0.18, 0.16, 0.13, 0.11, 0.09, 0.07, 0.04],
[0.20, 0.16, 0.14, 0.12, 0.10, 0.09, 0.07, 0.06, 0.06]
];
// ============ 常量与全局状态 ============
const W = 360, H = 640;
const LINE_Y = Math.round(H * 0.15); // 顶部红线 y
const WALL = 6; // 墙厚
const THROW_COOLDOWN = 480; // 投掷冷却 ms(PRD要求 480ms)
const MERGE_DELAY = 80; // 合成视觉延迟 ms(防同一帧连环合)
const COMBO_WINDOW = 900; // 连击窗口 ms
const NEW_BALL_IMMUNE = 100; // 新球合成免疫期 ms(仅防连环合)
const MIN_MOVE_THRESHOLD = 3; // 微动阈值:指针水平移动 < 3px 视为不动
const FIXED_STEP = 1000 / 60; // 60Hz 固定步长
const OVER_LINE_GRACE = 700; // 越线判定容差 ms
const { Engine, Bodies, Body, Composite, Events, Sleeping } = Matter;
const ballOpts = {
density: 0.001, friction: 0.7, frictionStatic: 0.8,
frictionAir: 0.015, restitution: 0.01, slop: 0.02,
sleepThreshold: 60
};
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let engine = null;
let balls = []; // 每个元素为 { body, level, isRemoved, timeAboveLine }
let pendingMerges = []; // 待合成候选 { a, b, time }
let pendingCreates = []; // 待延迟创建的新球 { x, y, level }
let particles = [], rings = [], floaters = [], confetti = [];
let score = 0, combo = 0, lastMergeTime = 0;
let maxUnlocked = 1; // 已解锁最高等级(1..9)
let currentLevel = 1, nextLevel = 1;
let heldX = W / 2;
let lastPointerX = -1; // 上次指针位置(防微动)
let cooldown = 0;
let state = 'playing'; // playing | over
let lastFrame = performance.now(), acc = 0;
// ============ 物理世界 ============
function createWorld() {
engine = Engine.create({
gravity: { y: 1 },
enableSleeping: true,
positionIterations: 8,
velocityIterations: 6
});
const left = Bodies.rectangle(WALL / 2, H / 2, WALL, H * 2, { isStatic: true, friction: 0.08, restitution: 0, label: 'wall' });
const right = Bodies.rectangle(W - WALL / 2, H / 2, WALL, H * 2, { isStatic: true, friction: 0.08, restitution: 0, label: 'wall' });
const floor = Bodies.rectangle(W / 2, H - WALL / 2, W * 2, WALL, { isStatic: true, friction: 0.3, restitution: 0, label: 'floor' });
Composite.add(engine.world, [left, right, floor]);
// 只在 collisionStart 中处理合成,绝不用 collisionActive
Events.on(engine, 'collisionStart', handleCollisions);
}
// ============ 合成判定 ============
function isMergeable(a, b) {
if (!a || !b || !a.gameData || !b.gameData) return false;
const da = a.gameData, db = b.gameData;
if (da === db) return false;
if (da.isRemoved || db.isRemoved) return false;
if (da.level !== db.level) return false;
if (a.isStatic || b.isStatic) return false;
// 检查 body 是否仍在世界中(已移除的 body position 访问可能异常)
if (!Composite.allBodies(engine.world).includes(a)) return false;
if (!Composite.allBodies(engine.world).includes(b)) return false;
const ra = LEVELS[da.level - 1].radius;
const rb = LEVELS[db.level - 1].radius;
const dx = a.position.x - b.position.x;
const dy = a.position.y - b.position.y;
const limit = ra + rb + 10; // 防止已分开的误触
return dx * dx + dy * dy <= limit * limit;
}
function handleCollisions(event) {
for (const pair of event.pairs) {
const a = pair.bodyA, b = pair.bodyB;
if (!isMergeable(a, b)) continue;
// 防止同一 body 同时参与多对合并(一个球同时与多个球碰撞)
const aBusy = pendingMerges.some(function (q) { return q.a === a || q.b === a; });
const bBusy = pendingMerges.some(function (q) { return q.a === b || q.b === b; });
if (aBusy || bBusy) continue;
pendingMerges.push({ a: a, b: b, time: performance.now() });
}
}
function processMerges() {
if (!pendingMerges.length) return;
const now = performance.now();
const remaining = [];
for (const q of pendingMerges) {
if (now - q.time >= MERGE_DELAY) doMerge(q.a, q.b);
else remaining.push(q);
}
pendingMerges = remaining;
}
function doMerge(bodyA, bodyB) {
if (!isMergeable(bodyA, bodyB)) return;
const da = bodyA.gameData, db = bodyB.gameData;
const level = da.level;
const ra = LEVELS[level - 1].radius;
// 额外安全检查:body 必须还在世界中(以防同一帧被其他合并移除)
if (!Composite.allBodies(engine.world).includes(bodyA)) { da.isRemoved = true; return; }
if (!Composite.allBodies(engine.world).includes(bodyB)) { db.isRemoved = true; return; }
// 先冻结两个旧球,再从世界移除
// 注意:顺序不能错——先读 position 再 setStatic 再 remove
const ma = bodyA.mass, mb = bodyB.mass;
const cx = (bodyA.position.x * ma + bodyB.position.x * mb) / (ma + mb);
const cy = (bodyA.position.y * ma + bodyB.position.y * mb) / (ma + mb);
Body.setStatic(bodyA, true);
Body.setStatic(bodyB, true);
da.isRemoved = true; db.isRemoved = true;
Composite.remove(engine.world, bodyA);
Composite.remove(engine.world, bodyB);
spawnMergeEffects(cx, cy, level);
const newLevel = level + 1;
let gained;
if (newLevel <= 9) {
gained = newLevel * 10 + comboBonus();
addScore(gained);
spawnFloatingText(cx, cy, '+' + gained);
createBall(cx, cy, newLevel);
if (newLevel > maxUnlocked) maxUnlocked = newLevel;
if (newLevel === 9) startCelebration(); // 合成上海交大:全屏撒花
wakeNearbyBalls(cx, cy, ra + LEVELS[newLevel - 1].radius + 30);
} else {
// 两个 9 级上交再合成:移除,无第 10 级
gained = 90 + comboBonus();
addScore(gained);
spawnFloatingText(cx, cy, '+' + gained);
wakeNearbyBalls(cx, cy, ra + 40);
}
}
function comboBonus() {
const now = performance.now();
if (now - lastMergeTime < COMBO_WINDOW) combo++;
else combo = 1;
lastMergeTime = now;
return combo > 1 ? (combo - 1) * 5 : 0;
}
function addScore(n) { score += n; }
// ============ 球 ============
function createBall(x, y, level) {
const radius = LEVELS[level - 1].radius;
const body = Bodies.circle(x, y, radius, ballOpts);
body.label = 'ball';
const gd = {
body: body,
level: level,
isRemoved: false,
timeAboveLine: 0,
createdAt: performance.now() // 创建时间,用于免疫期
};
body.gameData = gd;
balls.push(gd);
Composite.add(engine.world, body);
return gd;
}
function pickLevel() {
const weights = WEIGHTS[maxUnlocked - 1];
let total = 0;
for (const w of weights) total += w;
let r = Math.random() * total;
for (let i = 0; i < weights.length; i++) {
r -= weights[i];
if (r <= 0) return i + 1;
}
return weights.length;
}
// 投掷新球
function dropBall() {
const radius = LEVELS[currentLevel - 1].radius;
const x = clamp(heldX, radius + WALL, W - radius - WALL);
const y = LINE_Y - radius - 10;
createBall(x, y, currentLevel);
wakeNearbyBalls(x, y, radius + 20);
currentLevel = nextLevel;
nextLevel = pickLevel();
cooldown = THROW_COOLDOWN;
}
// ============ 堆叠稳定性:强制唤醒附近球 ============
function wakeNearbyBalls(cx, cy, radius) {
const r2 = radius * radius;
for (const gd of balls) {
if (gd.isRemoved) continue;
const dx = gd.body.position.x - cx;
const dy = gd.body.position.y - cy;
if (dx * dx + dy * dy < r2) {
gd.body.sleepThreshold = 0; // 强制唤醒
gd.body.sleepThreshold = 60; // 恢复原阈值
if (gd.body.isSleeping) Sleeping.set(gd.body, false);
}
}
}
// ============ 游戏结束判定 ============
function checkGameOver(dt) {
for (const gd of balls) {
if (gd.isRemoved) continue;
const top = gd.body.position.y - LEVELS[gd.level - 1].radius;
if (top < LINE_Y) {
gd.timeAboveLine += dt;
if (gd.timeAboveLine > OVER_LINE_GRACE) { triggerGameOver(); return; }
} else {
gd.timeAboveLine = 0;
}
}
}
function triggerGameOver() {
if (state !== 'playing') return;
state = 'over';
document.getElementById('finalScore').textContent = String(score);
document.getElementById('overlay').classList.remove('hidden');
}
// ============ 特效 ============
function spawnMergeEffects(x, y, level) {
const color = LEVELS[level - 1].color;
const count = 12 + level * 2;
for (let i = 0; i < count; i++) {
const ang = Math.random() * Math.PI * 2;
const sp = 0.05 + Math.random() * 0.18;
particles.push({
x: x, y: y,
vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 0.05,
life: 0, maxLife: 450 + Math.random() * 250,
size: 2 + Math.random() * 3.5, color: color
});
}
rings.push({ x: x, y: y, r: 6, maxR: LEVELS[level - 1].radius * 2.4, life: 0, maxLife: 320 });
}
function spawnFloatingText(x, y, text) {
floaters.push({ x: x, y: y, text: text, life: 0, maxLife: 900 });
}
function startCelebration() {
for (let i = 0; i < 90; i++) {
// 随机等级 1-9,尺寸统一在合理范围(2~3 级校徽的 v 大小)
const badgeLevel = Math.floor(Math.random() * 9) + 1;
const baseSize = LEVELS[badgeLevel - 1].radius * 1.4;
const size = Math.max(24, Math.min(42, baseSize));
confetti.push({
x: Math.random() * W,
y: -20 - Math.random() * 120,
vx: (Math.random() - 0.5) * 0.5,
vy: 0.4 + Math.random() * 1.2,
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.01,
w: size,
h: size,
life: 0, maxLife: 2600 + Math.random() * 1200,
phase: Math.random() * Math.PI * 2,
badgeLevel: badgeLevel
});
}
}
// ============ 主循环:60Hz 固定步长累积器 ============
function loop(now) {
requestAnimationFrame(loop);
let dt = now - lastFrame;
lastFrame = now;
if (dt > 120) dt = 120;
acc += dt;
if (acc > 250) acc = 250;
while (acc >= FIXED_STEP) {
Engine.update(engine, FIXED_STEP);
acc -= FIXED_STEP;
}
update(dt);
render();
}
function update(dt) {
cooldown = Math.max(0, cooldown - dt);
processMerges();
if (state === 'playing') checkGameOver(dt);
// 特效更新
for (const pt of particles) {
pt.life += dt;
pt.x += pt.vx * dt; pt.y += pt.vy * dt;
pt.vy += 0.0016 * dt;
}
particles = particles.filter(function (pt) { return pt.life < pt.maxLife; });
for (const rg of rings) rg.life += dt;
rings = rings.filter(function (rg) { return rg.life < rg.maxLife; });
for (const ft of floaters) {
ft.life += dt;
ft.y -= 0.03 * dt;
}
floaters = floaters.filter(function (ft) { return ft.life < ft.maxLife; });
for (const cf of confetti) {
cf.life += dt;
cf.x += cf.vx * dt + Math.sin(cf.phase + cf.life * 0.01) * 0.2;
cf.y += cf.vy * dt;
cf.vy += 0.0018 * dt;
cf.rot += cf.vr * dt;
}
confetti = confetti.filter(function (cf) { return cf.life < cf.maxLife && cf.y < H + 40; });
// 清理已移除球
balls = balls.filter(function (g) { return !g.isRemoved; });
}
// ============ 渲染 ============
function render() {
// 渐变背景(浅色 → 淡蓝/淡紫)
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#fdf9f1');
grad.addColorStop(0.55, '#dce9fb');
grad.addColorStop(1, '#e6dcf7');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// 侧墙与地面
ctx.fillStyle = 'rgba(120,140,180,0.28)';
ctx.fillRect(0, 0, WALL, H);
ctx.fillRect(W - WALL, 0, WALL, H);
ctx.fillRect(0, H - WALL, W, WALL);
// 顶部蓝色半透明 HUD
ctx.fillStyle = 'rgba(26,70,160,0.55)';
ctx.fillRect(0, 0, W, 46);
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(0, 46, W, 1);
ctx.textBaseline = 'middle';
// 左侧得分
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.font = 'bold 12px "Microsoft YaHei", sans-serif';
ctx.fillText('得分', 12, 15);
ctx.font = 'bold 21px "Microsoft YaHei", sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText(String(score), 12, 33);
// 中间标题
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.font = 'bold 14px "Microsoft YaHei", sans-serif';
ctx.fillText('合成交大 · Integrate SJTU', W / 2, 18);
// 右侧下一个预览球
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.font = 'bold 11px "Microsoft YaHei", sans-serif';
ctx.fillText('下一个', W - 12, 13);
drawBadge(ctx, nextLevel, W - 30, 28, 15);
// 顶部虚线红线(危险线)
ctx.save();
ctx.setLineDash([9, 7]);
ctx.strokeStyle = 'rgba(229,57,53,0.85)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, LINE_Y); ctx.lineTo(W, LINE_Y); ctx.stroke();
ctx.restore();
if (state === 'playing') {
// 预告球 + 垂直虚线预测落点
const hr = LEVELS[currentLevel - 1].radius;
const hx = clamp(heldX, hr + WALL, W - hr - WALL);
const hy = LINE_Y - hr - 10;
const landingY = predictLandingY(hx, hr);
ctx.save();
ctx.setLineDash([4, 5]);
ctx.strokeStyle = 'rgba(30,60,120,0.35)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(hx, hy + hr); ctx.lineTo(hx, landingY); ctx.stroke();
ctx.restore();
ctx.fillStyle = 'rgba(30,60,120,0.25)';
ctx.beginPath(); ctx.arc(hx, landingY, 5, 0, Math.PI * 2); ctx.fill();
ctx.save();
if (cooldown > 0) ctx.globalAlpha = 0.5;
drawBadge(ctx, currentLevel, hx, hy, hr);
ctx.restore();
ctx.globalAlpha = 1;
}
// 所有球
for (const gd of balls) {
if (gd.isRemoved) continue;
const r = LEVELS[gd.level - 1].radius;
drawBadge(ctx, gd.level, gd.body.position.x, gd.body.position.y, r);
}
// 粒子爆炸
for (const pt of particles) {
ctx.globalAlpha = Math.max(0, 1 - pt.life / pt.maxLife);
ctx.fillStyle = pt.color;
ctx.beginPath(); ctx.arc(pt.x, pt.y, pt.size, 0, Math.PI * 2); ctx.fill();
}
// 白色闪烁环
for (const rg of rings) {
const t = Math.min(1, rg.life / rg.maxLife);
const radius = rg.r + (rg.maxR - rg.r) * (1 - Math.pow(1 - t, 3));
ctx.globalAlpha = 1 - t;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 4 * (1 - t) + 1;
ctx.beginPath(); ctx.arc(rg.x, rg.y, radius, 0, Math.PI * 2); ctx.stroke();
}
// 浮动得分文字
for (const ft of floaters) {
const t = Math.min(1, ft.life / ft.maxLife);
ctx.globalAlpha = 1 - t;
ctx.font = 'bold 16px "Microsoft YaHei", sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.strokeText(ft.text, ft.x, ft.y);
ctx.fillStyle = '#ffffff';
ctx.fillText(ft.text, ft.x, ft.y);
}
// 撒花 confetti(含校徽版)
for (const cf of confetti) {
const t = Math.min(1, cf.life / cf.maxLife);
ctx.save();
ctx.globalAlpha = t > 0.8 ? (1 - t) / 0.2 : 1;
ctx.translate(cf.x, cf.y);
ctx.rotate(cf.rot);
if (cf.badgeLevel && BADGE_CACHE[cf.badgeLevel - 1]) {
const badge = BADGE_CACHE[cf.badgeLevel - 1];
const size = cf.w;
ctx.drawImage(badge, -size / 2, -size / 2, size, size);
} else {
ctx.fillStyle = cf.color;
ctx.fillRect(-cf.w / 2, -cf.h / 2, cf.w, cf.h);
}
ctx.restore();
}
ctx.globalAlpha = 1;
}
// 预测落点:垂直线上第一个被挡住的球/地面
function predictLandingY(x, r) {
let ly = H - WALL - r;
for (const gd of balls) {
if (gd.isRemoved) continue;
const bx = gd.body.position.x, by = gd.body.position.y;
const rr = LEVELS[gd.level - 1].radius + r;
const dx = Math.abs(x - bx);
if (dx < rr) {
const ty = by - Math.sqrt(rr * rr - dx * dx);
if (ty < ly) ly = ty;
}
}
return ly;
}
// 校徽绘制:优先缓存,未就绪时用特征色圆兜底(无文字)
function drawBadge(ctx2, level, x, y, r) {
const badge = BADGE_CACHE[level - 1];
ctx2.save();
ctx2.shadowColor = 'rgba(0,0,0,0.25)';
ctx2.shadowBlur = 6;
ctx2.shadowOffsetY = 2;
if (badge) {
// 校徽缓存取大一点让它更清楚(撑满球体)
ctx2.drawImage(badge, x - r, y - r, r * 2, r * 2);
} else {
// 兜底:仅显示特征色圆(无文字)
const info = LEVELS[level - 1];
ctx2.fillStyle = info.color;
ctx2.beginPath(); ctx2.arc(x, y, r, 0, Math.PI * 2); ctx2.fill();
}
ctx2.restore();
ctx2.strokeStyle = 'rgba(255,255,255,0.4)';
ctx2.lineWidth = 2;
ctx2.beginPath(); ctx2.arc(x, y, r, 0, Math.PI * 2); ctx2.stroke();
}
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
// ============ 输入:pointer 事件 ============
function toGameX(clientX) {
const rect = canvas.getBoundingClientRect();
return (clientX - rect.left) * (W / rect.width);
}
canvas.addEventListener('pointerdown', function (e) {
if (state !== 'playing') return;
canvas.setPointerCapture(e.pointerId);
heldX = lastPointerX = toGameX(e.clientX);
});
canvas.addEventListener('pointermove', function (e) {
if (state !== 'playing') return;
const newX = toGameX(e.clientX);
if (Math.abs(newX - lastPointerX) >= MIN_MOVE_THRESHOLD) {
heldX = newX;
lastPointerX = newX;
}
});
canvas.addEventListener('pointerup', function (e) {
if (state !== 'playing') return;
heldX = toGameX(e.clientX);
if (cooldown <= 0) dropBall();
});
canvas.addEventListener('pointercancel', function (e) {
if (state !== 'playing') return;
heldX = toGameX(e.clientX);
if (cooldown <= 0) dropBall();
});
canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); });
// ============ 重新开始 ============
function restart() {
createWorld();
balls = []; pendingMerges = [];
particles = []; rings = []; floaters = []; confetti = [];
score = 0; combo = 0; lastMergeTime = 0;
maxUnlocked = 1;
cooldown = 0;
currentLevel = pickLevel();
nextLevel = pickLevel();
heldX = W / 2;
state = 'playing';
document.getElementById('overlay').classList.add('hidden');
}
// ============ 启动 ============
function init() {
preloadBadges();
createWorld();
currentLevel = pickLevel();
nextLevel = pickLevel();
document.getElementById('restartBtn').addEventListener('click', restart);
requestAnimationFrame(loop);
}
init();
})();
</script>
</body>
</html>