上传文件至「/」

This commit is contained in:
guzhujushi committed 2026-08-27 22:54:14 +08:00
commit 790f8099a6
5 files changed
+7553

No files matched your search

+741
View File
@@ -0,0 +1,741 @@
<!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>