742 lines
28 KiB
HTML
742 lines
28 KiB
HTML
<!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>
|