上传文件至「/」
This commit is contained in:
commit
790f8099a6
5 files changed
+7553
No files matched your search
+741
@@ -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>
|
||||
Reference in new issue
Block a user