码桶
发现社区成员的开源项目
404.html54.5 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 · 赛道模式 | Race Day</title>
<script src="https://unpkg.com/[email protected]/build/three.min.js"></script>
<style>
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%; overflow: hidden;
background: #9fd9ff; color: #16202b;
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased; user-select: none;
}
#canvas-container { position: fixed; inset: 0; z-index: 0; }
#loading {
position: fixed; inset: 0; z-index: 100; display: flex;
align-items: center; justify-content: center;
background: linear-gradient(180deg, #2b8ee6 0%, #bfe8ff 100%);
color: #fff; font-size: 20px; font-weight: 800; letter-spacing: 3px;
}
/* ---------- 左上信息卡 ---------- */
.hud-card {
position: fixed; top: 18px; left: 18px; z-index: 10;
padding: 14px 18px 12px; border-radius: 20px;
background: rgba(255, 255, 255, 0.94);
border: 3px solid #fff;
box-shadow: 0 10px 0 rgba(15, 40, 70, 0.12), 0 20px 34px rgba(15, 40, 70, 0.2);
pointer-events: none; min-width: 232px;
}
.brand { display: flex; align-items: baseline; gap: 8px; }
.brand-num {
font-size: 34px; font-weight: 900; font-style: italic; letter-spacing: -1px;
color: #ff5a2b; text-shadow: 0 2px 0 #ffd23f;
}
.brand-sub { font-size: 11px; font-weight: 800; letter-spacing: 3px; color: #7b8794; }
.stat-row { display: flex; gap: 10px; margin-top: 8px; }
.stat { flex: 1; text-align: center; padding: 6px 4px 5px; border-radius: 12px; background: #eef6ff; }
.stat .k { font-size: 10px; font-weight: 700; color: #7f93a8; letter-spacing: 1px; }
.stat .v {
font-size: 19px; font-weight: 900; font-style: italic; color: #1f6fd0;
font-variant-numeric: tabular-nums; line-height: 1.15;
}
.track-tag {
margin-top: 8px; display: inline-block; padding: 4px 12px; border-radius: 999px;
background: linear-gradient(120deg, #ffd23f, #ff9a1a);
font-size: 11px; font-weight: 800; color: #6a3a00; letter-spacing: 1px;
}
/* ---------- 雷达小地图 ---------- */
.minimap-wrap {
position: fixed; top: 18px; right: 18px; width: 176px; height: 176px;
border-radius: 50%; overflow: hidden; z-index: 10;
border: 5px solid #fff; background: #eaf7ff;
box-shadow: 0 10px 0 rgba(15, 40, 70, 0.12), 0 18px 32px rgba(15, 40, 70, 0.22);
}
.minimap-wrap canvas { width: 100%; height: 100%; display: block; }
/* ---------- 速度表 ---------- */
.speedo {
position: fixed; left: 22px; bottom: 22px; z-index: 10;
padding: 10px 16px 12px; border-radius: 18px;
background: rgba(255, 255, 255, 0.94);
border: 3px solid #fff; pointer-events: none;
box-shadow: 0 10px 0 rgba(15, 40, 70, 0.12), 0 18px 30px rgba(15, 40, 70, 0.18);
}
.speedo-num {
font-size: 34px; font-weight: 900; font-style: italic; color: #10243a;
font-variant-numeric: tabular-nums; line-height: 1;
}
.speedo-num small { font-size: 11px; font-style: normal; color: #7f93a8; margin-left: 5px; letter-spacing: 1px; }
.speedo-bar { margin-top: 7px; width: 150px; height: 10px; border-radius: 999px; background: #dce8f5; overflow: hidden; }
.speedo-bar i {
display: block; height: 100%; width: 0%;
border-radius: 999px; background: linear-gradient(90deg, #35d07f, #ffd23f 55%, #ff5a2b);
transition: width 0.08s linear;
}
/* ---------- 道具提示 ---------- */
.item-prompt {
position: fixed; left: 50%; bottom: 26px; z-index: 20;
display: flex; align-items: center; gap: 14px;
padding: 12px 22px 12px 14px; border-radius: 22px;
background: rgba(255, 255, 255, 0.96); border: 3px solid #fff;
box-shadow: 0 12px 0 rgba(15, 40, 70, 0.14), 0 22px 40px rgba(15, 40, 70, 0.24);
transform: translate(-50%, 34px); opacity: 0; pointer-events: none;
transition: transform 0.32s cubic-bezier(0.2, 0.9, 0.3, 1.3), opacity 0.28s ease;
}
.item-prompt.show { transform: translate(-50%, 0); opacity: 1; pointer-events: auto; cursor: pointer; }
.ip-icon {
width: 50px; height: 50px; border-radius: 16px; flex: 0 0 auto;
display: flex; align-items: center; justify-content: center; font-size: 24px;
background: var(--accent, #22c55e); color: #fff;
box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.16);
}
.ip-title { font-size: 19px; font-weight: 900; font-style: italic; color: #16202b; }
.ip-title b { color: var(--accent, #22c55e); }
.ip-hint { font-size: 12px; color: #6c7d8f; margin-top: 2px; font-weight: 600; }
.ip-hint kbd {
background: #16202b; color: #fff; border-radius: 6px; padding: 1px 7px;
font-family: inherit; font-size: 11px; font-weight: 800;
}
.hint {
position: fixed; right: 22px; bottom: 24px; z-index: 10;
font-size: 12px; font-weight: 700; color: #fff; letter-spacing: 0.5px;
text-shadow: 0 2px 8px rgba(10, 40, 70, 0.55); pointer-events: none;
}
/* ---------- 倒计时 ---------- */
#countdown {
position: fixed; left: 50%; top: 40%; z-index: 30; display: none;
transform: translate(-50%, -50%);
font-size: 150px; font-weight: 900; font-style: italic; color: #fff;
text-shadow: 0 10px 0 rgba(15, 40, 70, 0.2), 0 0 46px rgba(255, 255, 255, 0.7);
pointer-events: none;
}
/* ---------- 过关 ---------- */
.levelup {
position: fixed; inset: 0; z-index: 40; display: flex;
align-items: center; justify-content: center;
background: rgba(8, 30, 58, 0.32); backdrop-filter: blur(4px);
opacity: 0; pointer-events: none; transition: opacity 0.35s ease;
}
.levelup.show { opacity: 1; }
.lu-card {
background: #fff; border-radius: 30px; padding: 34px 56px 30px;
text-align: center; border: 4px solid #fff;
box-shadow: 0 18px 0 rgba(15, 40, 70, 0.16), 0 30px 60px rgba(15, 40, 70, 0.3);
transform: translateY(14px) scale(0.96); transition: transform 0.35s cubic-bezier(0.2, 0.9, 0.3, 1.3);
}
.levelup.show .lu-card { transform: translateY(0) scale(1); }
.lu-kicker {
display: inline-block; padding: 5px 16px; border-radius: 999px;
background: #ff5a2b; color: #fff; font-size: 12px; font-weight: 900;
letter-spacing: 4px; font-style: italic;
}
.lu-title { font-size: 34px; font-weight: 900; font-style: italic; margin-top: 14px; color: #16202b; }
.lu-time { font-size: 15px; color: #64778a; margin-top: 8px; font-weight: 700; }
.lu-next { font-size: 13px; color: #1f6fd0; margin-top: 16px; font-weight: 800; }
.toast {
position: fixed; left: 50%; top: 22%; z-index: 25;
transform: translate(-50%, -12px); opacity: 0; pointer-events: none;
padding: 10px 24px; border-radius: 999px;
background: rgba(22, 32, 43, 0.82); color: #fff;
font-size: 15px; font-weight: 800; letter-spacing: 1px;
transition: all 0.3s ease; white-space: nowrap;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- 氮气按钮 ---------- */
.nitro-btn {
position: fixed; right: 22px; bottom: 62px; width: 98px; height: 98px;
border-radius: 50%; border: none; padding: 0; z-index: 15; cursor: pointer;
background: conic-gradient(#22d3ee var(--p, 0%), rgba(255, 255, 255, 0.45) 0);
box-shadow: 0 10px 0 rgba(15, 40, 70, 0.14), 0 18px 32px rgba(15, 40, 70, 0.26);
display: flex; align-items: center; justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.nitro-btn::after {
content: ''; position: absolute; inset: 8px; border-radius: 50%;
background: #ffffff; box-shadow: inset 0 4px 12px rgba(20, 60, 90, 0.14);
}
.nitro-core {
position: relative; z-index: 1; text-align: center; line-height: 1.05;
pointer-events: none;
}
.nitro-core b { display: block; font-size: 19px; font-style: italic; font-weight: 900; color: #0f7f96; }
.nitro-core span { display: block; font-size: 10px; font-weight: 800; color: #7f93a8; letter-spacing: 1px; margin-top: 2px; }
.nitro-btn.ready { animation: nitroPulse 1.6s ease-in-out infinite; }
.nitro-btn.spent { opacity: 0.55; cursor: default; }
.nitro-btn:active { transform: translateY(2px); }
@keyframes nitroPulse {
0%, 100% { box-shadow: 0 10px 0 rgba(15, 40, 70, 0.14), 0 0 0 0 rgba(34, 211, 238, 0.5); }
50% { box-shadow: 0 10px 0 rgba(15, 40, 70, 0.14), 0 0 0 12px rgba(34, 211, 238, 0); }
}
/* ---------- 氮气视觉 ---------- */
#boostFx {
position: fixed; inset: 0; z-index: 5; pointer-events: none; opacity: 0;
transition: opacity 0.22s ease;
background: radial-gradient(ellipse at center, rgba(180, 245, 255, 0) 34%, rgba(95, 215, 255, 0.34) 100%);
}
#boostFx::before {
content: ''; position: absolute; inset: -25%;
background: repeating-linear-gradient(102deg, rgba(255, 255, 255, 0.5) 0 2px, transparent 2px 68px);
opacity: 0.22; transform: translateX(0)
}
body.boosting #boostFx { opacity: 1; }
body.boosting #boostFx::before { animation: speedStreak 0.38s linear infinite; }
@keyframes speedStreak { from { transform: translateX(-68px); } to { transform: translateX(0); } }
</style>
</head>
<body>
<div id="loading">正在准备赛道…</div>
<div id="canvas-container"></div>
<div id="boostFx"></div>
<div class="hud-card">
<div class="brand">
<span class="brand-num">404</span>
<span class="brand-sub">RACE DAY</span>
</div>
<div class="stat-row">
<div class="stat"><div class="k">关卡</div><div class="v" id="levelVal">1</div></div>
<div class="stat"><div class="k">圈数</div><div class="v" id="lapVal">1/2</div></div>
<div class="stat"><div class="k">用时</div><div class="v" id="timeVal">0.0</div></div>
</div>
<div class="track-tag" id="trackName">晴空赛道</div>
</div>
<div class="minimap-wrap">
<canvas id="minimap" width="352" height="352"></canvas>
</div>
<div class="speedo">
<div class="speedo-num"><span id="speedNum">0</span><small>km/h</small></div>
<div class="speedo-bar"><i id="speedBar"></i></div>
</div>
<div class="item-prompt" id="itemPrompt">
<div class="ip-icon" id="promptIcon">🎁</div>
<div class="ip-body">
<div class="ip-title">前往 <b id="targetName">主页</b></div>
<div class="ip-hint">按 <kbd>ENTER</kbd> 打开 · 也可直接点击</div>
</div>
</div>
<div class="hint">W 加速 · S 刹车 · A / D 转向 · SPACE 氮气 · ENTER 打开道具</div>
<button type="button" id="nitroBtn" class="nitro-btn" style="--p:100%">
<span class="nitro-core"><b>氮气</b><span id="nitroPct">100%</span></span>
</button>
<div id="countdown">3</div>
<div class="levelup" id="levelup">
<div class="lu-card">
<div class="lu-kicker">FINISH!</div>
<div class="lu-title">第 <span id="luLevel">1</span> 关完成</div>
<div class="lu-time">用时 <span id="luTime">0.0</span> 秒</div>
<div class="lu-next">正在进入第 <span id="luNext">2</span> 关…</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
/* =========================================================
404 · 赛道模式
白天卡丁车:专业环道 / 卡丁车 / 道具箱(可跳转页面)/ 冲线进入下一关
========================================================= */
const CONFIG = {
destinations: [
{ text: '主页', url: 'https://bok.wang/', color: '#22c55e' },
{ text: '博客', url: 'https://lxlss.com', color: '#3b82f6' },
{ text: 'GitHub', url: 'https://github.com/9llll', color: '#a855f7' },
{ text: '联系我', url: 'https://1c1.top/', color: '#f59e0b' }
],
lapsPerLevel: 2, // 每关圈数
roadHalfWidth: 7, // 赛道半宽
samples: 720 // 赛道采样点
};
const PALETTES = [
{ name: '晴空赛道', skyTop: 0x2b8ee6, skyBottom: 0xd8f1ff, fog: 0xd8efff, hemiSky: 0xcdeaff, hemiGround: 0x69a84a, ground: 0x7ac94f, road: '#4a4f5c' },
{ name: '阳光环道', skyTop: 0x1f80d8, skyBottom: 0xeaf8ff, fog: 0xe2f5ff, hemiSky: 0xd7f0ff, hemiGround: 0x74b04a, ground: 0x8ad35a, road: '#4d5260' },
{ name: '午后飞驰', skyTop: 0x2f8fe2, skyBottom: 0xfff0d2, fog: 0xf3f4e6, hemiSky: 0xd8efff, hemiGround: 0x6fae45, ground: 0x74c24a, road: '#50515f' },
{ name: '青草直线', skyTop: 0x33a0e6, skyBottom: 0xe9fbff, fog: 0xe4f8ff, hemiSky: 0xd5f1ff, hemiGround: 0x70ab47, ground: 0x82cf54, road: '#484e5b' }
];
/* ---------------- 全局状态 ---------------- */
let scene, camera, renderer;
let car, carBody, wheels = [];
let world = null; // 每一关的场景容器
let track = null; // { samples, length, maxR }
let skyDome, hemi, sun, groundMat;
let items = [];
let treeColliders = [];
let activeItem = null;
let palette = PALETTES[0];
let level = 1;
let lapsDone = 0;
let progress = 0; // 沿赛道的累计进度(1 = 一圈)
let lastU = 0;
let lastIdx = 0;
let offRoad = false;
let speed = 0;
let state = 'loading'; // countdown | race | levelup
let countdownStart = 0;
let raceStart = 0;
let raceTime = 0;
let navigating = false;
let toastTimer = 0;
/* 氮气 */
const NITRO_MAX = 100;
const NITRO_COST = 40;
let nitro = NITRO_MAX;
let boosting = false;
let boostEnd = 0;
let flames = [];
const keys = { w: false, a: false, s: false, d: false };
/* ---------------- DOM ---------------- */
const levelEl = document.getElementById('levelVal');
const lapEl = document.getElementById('lapVal');
const timeEl = document.getElementById('timeVal');
const trackNameEl = document.getElementById('trackName');
const speedNumEl = document.getElementById('speedNum');
const speedBarEl = document.getElementById('speedBar');
const promptEl = document.getElementById('itemPrompt');
const targetNameEl = document.getElementById('targetName');
const promptIconEl = document.getElementById('promptIcon');
const countdownEl = document.getElementById('countdown');
const levelupEl = document.getElementById('levelup');
const luLevelEl = document.getElementById('luLevel');
const luTimeEl = document.getElementById('luTime');
const luNextEl = document.getElementById('luNext');
const toastEl = document.getElementById('toast');
const nitroBtnEl = document.getElementById('nitroBtn');
const nitroPctEl = document.getElementById('nitroPct');
let mmCtx = null;
/* =========================================================
启动
========================================================= */
window.addEventListener('load', boot);
function boot() {
if (typeof THREE === 'undefined') {
const el = document.getElementById('loading');
el.textContent = '引擎加载失败,请检查网络后刷新';
el.style.color = '#ffdddd';
return;
}
try {
init();
document.getElementById('loading').style.display = 'none';
animate();
} catch (err) {
console.error(err);
const el = document.getElementById('loading');
el.textContent = '初始化失败:' + err.message;
el.style.fontSize = '15px';
}
}
function init() {
const container = document.getElementById('canvas-container');
scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xd8efff, 340, 1000);
camera = new THREE.PerspectiveCamera(62, window.innerWidth / window.innerHeight, 0.1, 3000);
camera.position.set(0, 24, 0);
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NoToneMapping;
container.appendChild(renderer.domElement);
/* 光照:白天顶光 + 暖阳光 */
hemi = new THREE.HemisphereLight(0xcdeaff, 0x69a84a, 1.15);
scene.add(hemi);
sun = new THREE.DirectionalLight(0xfff6dc, 2.35);
sun.position.set(170, 250, 130);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -230;
sun.shadow.camera.right = 230;
sun.shadow.camera.top = 230;
sun.shadow.camera.bottom = -230;
sun.shadow.camera.near = 1;
sun.shadow.camera.far = 900;
sun.shadow.bias = -0.0006;
sun.shadow.normalBias = 0.7;
sun.target.position.set(0, 0, 0);
scene.add(sun);
scene.add(sun.target);
skyDome = createSky();
scene.add(skyDome);
createClouds();
createGround();
createCar();
mmCtx = document.getElementById('minimap').getContext('2d');
buildLevel(1);
window.addEventListener('resize', onWindowResize, false);
window.addEventListener('keydown', onKeyDown, false);
window.addEventListener('keyup', onKeyUp, false);
promptEl.addEventListener('click', openItem);
nitroBtnEl.addEventListener('click', tryBoost);
}
/* =========================================================
天空 / 云 / 草地
========================================================= */
function createSky() {
const geo = new THREE.SphereGeometry(1500, 32, 16);
const mat = new THREE.ShaderMaterial({
uniforms: {
topColor: { value: new THREE.Color(0x2b8ee6) },
bottomColor: { value: new THREE.Color(0xd8f1ff) }
},
vertexShader: [
'varying vec3 vPos;',
'void main() {',
' vPos = position;',
' gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);',
'}'
].join('\n'),
fragmentShader: [
'uniform vec3 topColor;',
'uniform vec3 bottomColor;',
'varying vec3 vPos;',
'void main() {',
' float h = clamp(normalize(vPos).y * 1.7 + 0.12, 0.0, 1.0);',
' gl_FragColor = vec4(mix(bottomColor, topColor, h), 1.0);',
'}'
].join('\n'),
side: THREE.BackSide,
depthWrite: false,
fog: false
});
return new THREE.Mesh(geo, mat);
}
function createClouds() {
const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.92, fog: false });
const geo = new THREE.SphereGeometry(1, 12, 10);
for (let i = 0; i < 16; i++) {
const g = new THREE.Group();
const parts = 3 + Math.floor(Math.random() * 3);
for (let j = 0; j < parts; j++) {
const m = new THREE.Mesh(geo, mat);
m.position.set((Math.random() - 0.5) * 34, (Math.random() - 0.5) * 6, (Math.random() - 0.5) * 16);
m.scale.set(14 + Math.random() * 16, 8 + Math.random() * 6, 12 + Math.random() * 10);
g.add(m);
}
const a = Math.random() * Math.PI * 2;
const r = 280 + Math.random() * 520;
g.position.set(Math.cos(a) * r, 150 + Math.random() * 90, Math.sin(a) * r);
scene.add(g);
}
}
function createGround() {
const c = document.createElement('canvas');
c.width = c.height = 256;
const x = c.getContext('2d');
x.fillStyle = '#7ac94f';
x.fillRect(0, 0, 256, 256);
for (let i = 0; i < 2600; i++) {
const g = Math.random();
x.fillStyle = g > 0.5 ? 'rgba(255,255,255,0.10)' : 'rgba(20,90,30,0.14)';
x.fillRect(Math.random() * 256, Math.random() * 256, 2 + Math.random() * 4, 2 + Math.random() * 4);
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(110, 110);
tex.anisotropy = 4;
groundMat = new THREE.MeshStandardMaterial({ map: tex, color: 0xffffff, roughness: 1, metalness: 0 });
const ground = new THREE.Mesh(new THREE.PlaneGeometry(2000, 2000), groundMat);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
}
/* =========================================================
卡丁车
========================================================= */
function createCar() {
car = new THREE.Group();
carBody = new THREE.Group();
car.add(carBody);
const paint = new THREE.MeshStandardMaterial({ color: 0xff4d3f, roughness: 0.35, metalness: 0.3 });
const yellow = new THREE.MeshStandardMaterial({ color: 0xffd23f, roughness: 0.4, metalness: 0.2 });
const dark = new THREE.MeshStandardMaterial({ color: 0x232838, roughness: 0.75 });
const chrome = new THREE.MeshStandardMaterial({ color: 0xdfe6ef, roughness: 0.25, metalness: 0.85 });
const pan = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.4, 3.8), paint);
pan.position.y = 0.62;
carBody.add(pan);
[-1, 1].forEach(function (s) {
const pod = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.5, 2.0), yellow);
pod.position.set(s * 1.12, 0.72, 0.15);
carBody.add(pod);
});
const nose = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.36, 1.3), paint);
nose.position.set(0, 0.6, -2.05);
carBody.add(nose);
const bumper = new THREE.Mesh(new THREE.BoxGeometry(2.3, 0.28, 0.5), dark);
bumper.position.set(0, 0.5, -2.7);
carBody.add(bumper);
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.95, 1.0, 0.75), dark);
seat.position.set(0, 1.15, 0.62);
carBody.add(seat);
const col = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 0.75, 8), chrome);
col.position.set(0, 1.18, -0.35);
col.rotation.x = -0.5;
carBody.add(col);
const sw = new THREE.Mesh(new THREE.TorusGeometry(0.33, 0.07, 8, 20), dark);
sw.position.set(0, 1.4, -0.55);
sw.rotation.x = -0.9;
carBody.add(sw);
const helmet = new THREE.Mesh(
new THREE.SphereGeometry(0.42, 18, 14),
new THREE.MeshStandardMaterial({ color: 0x2ea8ff, roughness: 0.3, metalness: 0.15 })
);
helmet.position.set(0, 1.98, 0.55);
carBody.add(helmet);
const visor = new THREE.Mesh(new THREE.BoxGeometry(0.64, 0.26, 0.22), dark);
visor.position.set(0, 1.96, 0.86);
carBody.add(visor);
const wing = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.16, 0.55), yellow);
wing.position.set(0, 1.45, 1.72);
carBody.add(wing);
[-0.75, 0.75].forEach(function (x) {
const sup = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.6, 0.12), dark);
sup.position.set(x, 1.12, 1.72);
carBody.add(sup);
});
[-0.42, 0.42].forEach(function (x) {
const ex = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.13, 0.5, 10), chrome);
ex.position.set(x, 0.78, 1.95);
ex.rotation.x = Math.PI / 2;
carBody.add(ex);
});
/* 氮气火焰 */
const flameGeo = new THREE.ConeGeometry(0.27, 1.9, 10);
const flameMat = new THREE.MeshBasicMaterial({
color: 0x8beeff, transparent: true, opacity: 0.85,
blending: THREE.AdditiveBlending, depthWrite: false
});
[-0.42, 0.42].forEach(function (x) {
const f = new THREE.Mesh(flameGeo, flameMat);
f.position.set(x, 0.78, 3.25);
f.rotation.x = Math.PI / 2;
f.visible = false;
carBody.add(f);
flames.push(f);
});
const wheelGeo = new THREE.CylinderGeometry(0.56, 0.56, 0.5, 18);
const rimGeo = new THREE.CylinderGeometry(0.3, 0.3, 0.54, 12);
const tyreMat = new THREE.MeshStandardMaterial({ color: 0x1c1f26, roughness: 0.95 });
const rimMat = new THREE.MeshStandardMaterial({ color: 0xf2f5fa, roughness: 0.3, metalness: 0.8 });
const spots = [
[-1.16, -1.25, true], [1.16, -1.25, true],
[-1.16, 1.3, false], [1.16, 1.3, false]
];
spots.forEach(function (spot) {
const pivot = new THREE.Group();
pivot.position.set(spot[0], 0.56, spot[1]);
const spinner = new THREE.Group();
pivot.add(spinner);
const tyre = new THREE.Mesh(wheelGeo, tyreMat);
tyre.rotation.z = Math.PI / 2;
spinner.add(tyre);
const rim = new THREE.Mesh(rimGeo, rimMat);
rim.rotation.z = Math.PI / 2;
spinner.add(rim);
carBody.add(pivot);
wheels.push({ pivot: pivot, spinner: spinner, isFront: spot[2] });
});
car.traverse(function (o) {
if (o.isMesh) { o.castShadow = true; o.receiveShadow = false; }
});
scene.add(car);
}
/* =========================================================
赛道生成
========================================================= */
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function buildTrack(n) {
/* 赛道形状固定,每关循环复用,只更换场景素材 */
const rnd = mulberry32(20240608);
const nodeCount = 11;
const pts = [];
for (let i = 0; i < nodeCount; i++) {
const a = (i / nodeCount) * Math.PI * 2;
const r = 102 + (rnd() - 0.5) * 52;
pts.push(new THREE.Vector3(Math.cos(a) * r, 0, Math.sin(a) * r));
}
const curve = new THREE.CatmullRomCurve3(pts, true, 'catmullrom', 0.5);
curve.arcLengthDivisions = 1400;
const N = CONFIG.samples;
const samples = [];
for (let i = 0; i < N; i++) {
const u = i / N;
const p = curve.getPointAt(u);
const t = curve.getTangentAt(u).normalize();
const nrm = new THREE.Vector3(-t.z, 0, t.x).normalize();
samples.push({ p: p, t: t, n: nrm, u: u });
}
let maxR = 1;
samples.forEach(function (s) {
maxR = Math.max(maxR, Math.hypot(s.p.x, s.p.z));
});
return { curve: curve, samples: samples, length: curve.getLength(), maxR: maxR };
}
function disposeGroup(group) {
group.traverse(function (o) {
if (o.geometry) o.geometry.dispose();
if (o.material) {
const mats = Array.isArray(o.material) ? o.material : [o.material];
mats.forEach(function (m) {
if (m.map) m.map.dispose();
m.dispose();
});
}
});
}
function buildLevel(n) {
level = n;
palette = PALETTES[(n - 1) % PALETTES.length];
if (world) {
scene.remove(world);
disposeGroup(world);
}
world = new THREE.Group();
scene.add(world);
items = [];
treeColliders = [];
activeItem = null;
hidePrompt();
hideToast();
/* 天空 / 光照 / 草地换色 */
skyDome.material.uniforms.topColor.value.setHex(palette.skyTop);
skyDome.material.uniforms.bottomColor.value.setHex(palette.skyBottom);
scene.fog.color.setHex(palette.fog);
hemi.color.setHex(palette.hemiSky);
hemi.groundColor.setHex(palette.hemiGround);
groundMat.color.setHex(palette.ground);
track = buildTrack(n);
trackNameEl.textContent = palette.name;
buildRoad();
buildCurbs();
buildStartLine();
buildArch();
buildTrees();
buildFlags();
buildItems();
/* 发车位置:终点线前一点点 */
const N = track.samples.length;
const startIdx = (N - 18) % N;
const s0 = track.samples[startIdx];
car.position.set(s0.p.x, 0, s0.p.z);
car.rotation.set(0, Math.atan2(-s0.t.x, -s0.t.z), 0);
carBody.rotation.set(0, 0, 0);
carBody.position.y = 0;
speed = 0;
nitro = NITRO_MAX;
boosting = false;
boostEnd = 0;
document.body.classList.remove('boosting');
flames.forEach(function (f) { f.visible = false; });
lapsDone = 0;
progress = -18 / N;
lastU = startIdx / N;
lastIdx = startIdx;
offRoad = false;
/* 重置 HUD */
levelEl.textContent = level;
lapEl.textContent = '1/' + CONFIG.lapsPerLevel;
timeEl.textContent = '0.0';
speedNumEl.textContent = '0';
speedBarEl.style.width = '0%';
levelupEl.classList.remove('show');
state = 'countdown';
countdownStart = performance.now();
countdownEl.style.display = 'none';
}
function buildRoad() {
const samples = track.samples;
const N = samples.length;
const halfW = CONFIG.roadHalfWidth;
const c = document.createElement('canvas');
c.width = c.height = 256;
const x = c.getContext('2d');
x.fillStyle = '#5a5f6b';
x.fillRect(0, 0, 256, 256);
for (let i = 0; i < 6000; i++) {
const g = Math.random() * 44 - 22;
x.fillStyle = 'rgba(' + Math.round(120 + g) + ',' + Math.round(122 + g) + ',' + Math.round(130 + g) + ',0.35)';
x.fillRect(Math.random() * 256, Math.random() * 256, 2, 2);
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.anisotropy = 8;
const pos = [], uv = [], idx = [];
for (let i = 0; i <= N; i++) {
const s = samples[i % N];
pos.push(s.p.x + s.n.x * halfW, 0.02, s.p.z + s.n.z * halfW);
pos.push(s.p.x - s.n.x * halfW, 0.02, s.p.z - s.n.z * halfW);
const v = (i / N) * (track.length / 9);
uv.push(0, v, 1, v);
}
for (let i = 0; i < N; i++) {
const a = i * 2, b = a + 1, cc = a + 2, d = a + 3;
idx.push(a, cc, b, b, cc, d);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
geo.setIndex(idx);
geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({
map: tex, color: 0xffffff, roughness: 0.92, metalness: 0.05, side: THREE.DoubleSide
});
const road = new THREE.Mesh(geo, mat);
road.receiveShadow = true;
world.add(road);
}
function buildCurbs() {
const samples = track.samples;
const N = samples.length;
const halfW = CONFIG.roadHalfWidth;
const red = new THREE.Color(0xe23b3b);
const white = new THREE.Color(0xf7f7f7);
[1, -1].forEach(function (side) {
const pos = [], col = [], idx = [];
for (let i = 0; i <= N; i++) {
const s = samples[i % N];
const inn = halfW * side;
const out = (halfW + 1.5) * side;
pos.push(s.p.x + s.n.x * inn, 0.055, s.p.z + s.n.z * inn);
pos.push(s.p.x + s.n.x * out, 0.055, s.p.z + s.n.z * out);
const c = (Math.floor(i / 8) % 2 === 0) ? red : white;
col.push(c.r, c.g, c.b, c.r, c.g, c.b);
}
for (let i = 0; i < N; i++) {
const a = i * 2, b = a + 1, cc = a + 2, d = a + 3;
idx.push(a, cc, b, b, cc, d);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
geo.setIndex(idx);
geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({
vertexColors: true, roughness: 0.65, metalness: 0.02, side: THREE.DoubleSide
});
const curb = new THREE.Mesh(geo, mat);
curb.receiveShadow = true;
world.add(curb);
});
/* 中线虚线 */
const dashMat = new THREE.MeshStandardMaterial({ color: 0xf4f7ff, roughness: 0.7 });
const dashGeo = new THREE.PlaneGeometry(0.5, 3.6);
for (let i = 0; i < N; i += 14) {
const s = samples[i];
const g = new THREE.Group();
g.position.set(s.p.x, 0.04, s.p.z);
g.rotation.y = Math.atan2(-s.t.x, -s.t.z);
const m = new THREE.Mesh(dashGeo, dashMat);
m.rotation.x = -Math.PI / 2;
g.add(m);
world.add(g);
}
}
function buildStartLine() {
const s0 = track.samples[0];
const g = new THREE.Group();
g.position.set(s0.p.x, 0, s0.p.z);
g.rotation.y = Math.atan2(-s0.t.x, -s0.t.z);
world.add(g);
const darkMat = new THREE.MeshStandardMaterial({ color: 0x1b2130, roughness: 0.6 });
const lightMat = new THREE.MeshStandardMaterial({ color: 0xf7f9ff, roughness: 0.6 });
for (let col = 0; col < 10; col++) {
for (let row = 0; row < 2; row++) {
const tile = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 1.4), (col + row) % 2 === 0 ? darkMat : lightMat);
tile.rotation.x = -Math.PI / 2;
tile.position.set(-6.3 + col * 1.4, 0.045, row * 1.4 - 0.7);
tile.receiveShadow = true;
g.add(tile);
}
}
}
function buildArch() {
const s0 = track.samples[0];
const g = new THREE.Group();
g.position.set(s0.p.x, 0, s0.p.z);
g.rotation.y = Math.atan2(-s0.t.x, -s0.t.z);
world.add(g);
const whiteMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.45 });
const redMat = new THREE.MeshStandardMaterial({ color: 0xff4d3f, roughness: 0.45 });
const halfW = CONFIG.roadHalfWidth;
[-1, 1].forEach(function (side) {
const pillar = new THREE.Mesh(new THREE.BoxGeometry(1.5, 8.6, 1.5), whiteMat);
pillar.position.set(side * (halfW + 2.6), 4.3, 0);
pillar.castShadow = true;
g.add(pillar);
const cap = new THREE.Mesh(new THREE.BoxGeometry(2.1, 1.1, 2.1), redMat);
cap.position.set(side * (halfW + 2.6), 8.95, 0);
cap.castShadow = true;
g.add(cap);
});
const barW = (halfW + 3.3) * 2;
const bar = new THREE.Mesh(new THREE.BoxGeometry(barW, 2.6, 0.8), whiteMat);
bar.position.set(0, 9.7, 0);
bar.castShadow = true;
g.add(bar);
/* 横幅贴图 */
const cv = document.createElement('canvas');
cv.width = 1024; cv.height = 256;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#ff5a2b';
ctx.fillRect(0, 0, 1024, 256);
for (let i = 0; i < 16; i++) {
ctx.fillStyle = i % 2 === 0 ? '#101826' : '#ffffff';
ctx.fillRect(i * 64, 0, 64, 22);
ctx.fillRect(i * 64, 234, 64, 22);
}
ctx.fillStyle = '#ffffff';
ctx.font = 'italic 900 112px "Microsoft YaHei", Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('404 GRAND PRIX', 512, 132);
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
const bannerMat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.5 });
[1, -1].forEach(function (dir) {
const plane = new THREE.Mesh(new THREE.PlaneGeometry(barW - 0.5, 2.2), bannerMat);
plane.position.set(0, 9.7, dir * 0.42);
if (dir < 0) plane.rotation.y = Math.PI;
g.add(plane);
});
}
function distToTrackXZ(x, z) {
const samples = track.samples;
let best = Infinity;
for (let i = 0; i < samples.length; i += 5) {
const dx = samples[i].p.x - x;
const dz = samples[i].p.z - z;
const d = dx * dx + dz * dz;
if (d < best) best = d;
}
return Math.sqrt(best);
}
function buildTrees() {
const rnd = mulberry32(level * 337 + 7);
const trunkGeo = new THREE.CylinderGeometry(0.42, 0.6, 3.4, 6);
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 1 });
const leafGeo = new THREE.SphereGeometry(1, 8, 6);
const leafSets = [
[0x3fae4a, 0x59c65f],
[0x2f9e5a, 0x6fd07a],
[0x57a83f, 0x8ed15c],
[0x3f9e6a, 0x66c98a]
];
const leafSet = leafSets[(level - 1) % leafSets.length];
const leafA = new THREE.MeshStandardMaterial({ color: leafSet[0], roughness: 0.95, flatShading: true });
const leafB = new THREE.MeshStandardMaterial({ color: leafSet[1], roughness: 0.95, flatShading: true });
let placed = 0, tries = 0;
while (placed < 150 && tries < 2200) {
tries++;
const a = rnd() * Math.PI * 2;
const r = 42 + rnd() * 250;
const x = Math.cos(a) * r;
const z = Math.sin(a) * r;
if (distToTrackXZ(x, z) < CONFIG.roadHalfWidth + 10) continue;
const g = new THREE.Group();
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y = 1.7;
trunk.castShadow = true;
g.add(trunk);
const l1 = new THREE.Mesh(leafGeo, leafA);
l1.position.y = 4.1;
l1.scale.set(2.5, 2.0, 2.5);
l1.castShadow = true;
g.add(l1);
const l2 = new THREE.Mesh(leafGeo, leafB);
l2.position.set(0.3, 5.6, -0.2);
l2.scale.set(1.8, 1.5, 1.8);
l2.castShadow = true;
g.add(l2);
g.position.set(x, 0, z);
const sc = 0.75 + rnd() * 0.7;
g.scale.setScalar(sc);
g.rotation.y = rnd() * Math.PI * 2;
world.add(g);
treeColliders.push({ x: x, z: z, r: 1.0 * sc + 0.5 });
placed++;
}
}
function buildFlags() {
const samples = track.samples;
const N = samples.length;
const accentColors = [0xff5a2b, 0x22c55e, 0x3b82f6, 0xffd23f];
const accent = accentColors[(level - 1) % accentColors.length];
const poleGeo = new THREE.CylinderGeometry(0.08, 0.08, 4.4, 6);
const poleMat = new THREE.MeshStandardMaterial({ color: 0xf2f5fa, roughness: 0.55 });
const flagGeo = new THREE.PlaneGeometry(1.7, 1.0);
const flagMat = new THREE.MeshStandardMaterial({
color: accent, roughness: 0.7, metalness: 0.05, side: THREE.DoubleSide
});
for (let i = 0; i < N; i += 55) {
const s = samples[i];
const side = ((i / 55) % 2 === 0) ? 1 : -1;
const g = new THREE.Group();
g.position.set(
s.p.x + s.n.x * side * (CONFIG.roadHalfWidth + 3.4),
0,
s.p.z + s.n.z * side * (CONFIG.roadHalfWidth + 3.4)
);
g.rotation.y = Math.atan2(-s.t.x, -s.t.z);
const pole = new THREE.Mesh(poleGeo, poleMat);
pole.position.y = 2.2;
pole.castShadow = true;
g.add(pole);
const flag = new THREE.Mesh(flagGeo, flagMat);
flag.position.set(0.85, 3.55, 0);
flag.castShadow = true;
g.add(flag);
world.add(g);
}
}
/* =========================================================
道具箱(可跳转页面)
========================================================= */
function roundRectPath(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function itemTexture(color) {
const c = document.createElement('canvas');
c.width = c.height = 256;
const x = c.getContext('2d');
const grad = x.createLinearGradient(0, 0, 256, 256);
grad.addColorStop(0, color);
grad.addColorStop(1, '#ffffff');
roundRectPath(x, 10, 10, 236, 236, 48);
x.fillStyle = grad;
x.fill();
x.lineWidth = 14;
x.strokeStyle = '#ffffff';
x.stroke();
x.fillStyle = 'rgba(255,255,255,0.92)';
x.font = '900 150px Arial';
x.textAlign = 'center';
x.textBaseline = 'middle';
x.fillText('?', 128, 136);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function labelSprite(text, color) {
const c = document.createElement('canvas');
c.width = 320; c.height = 128;
const x = c.getContext('2d');
roundRectPath(x, 8, 22, 304, 84, 26);
x.fillStyle = color;
x.fill();
x.lineWidth = 8;
x.strokeStyle = 'rgba(255,255,255,0.95)';
x.stroke();
x.fillStyle = '#ffffff';
x.font = '900 42px "Microsoft YaHei", Arial';
x.textAlign = 'center';
x.textBaseline = 'middle';
x.fillText(text, 160, 66);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false });
const sp = new THREE.Sprite(mat);
sp.scale.set(7.4, 2.96, 1);
return sp;
}
function buildItems() {
const N = track.samples.length;
const total = CONFIG.destinations.length;
const rnd = mulberry32(level * 97 + 31);
const halfW = CONFIG.roadHalfWidth;
CONFIG.destinations.forEach(function (dest, k) {
const u = (0.14 + k * (0.82 / total) + (rnd() - 0.5) * 0.04 + 1) % 1;
const s = track.samples[Math.floor(u * N) % N];
const side = (k % 2 === 0) ? 1 : -1;
const g = new THREE.Group();
g.position.set(
s.p.x + s.n.x * side * halfW * 0.5,
0,
s.p.z + s.n.z * side * halfW * 0.5
);
const tex = itemTexture(dest.color);
const box = new THREE.Mesh(
new THREE.BoxGeometry(2.4, 2.4, 2.4),
new THREE.MeshStandardMaterial({
map: tex, emissive: new THREE.Color(dest.color), emissiveIntensity: 0.35,
roughness: 0.35, metalness: 0.15
})
);
box.position.y = 2.9;
box.castShadow = true;
g.add(box);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(2.25, 0.1, 8, 32),
new THREE.MeshStandardMaterial({
color: 0xffffff, emissive: new THREE.Color(dest.color), emissiveIntensity: 0.9, roughness: 0.3
})
);
ring.rotation.x = Math.PI / 2;
ring.position.y = 2.9;
g.add(ring);
const label = labelSprite(dest.text, dest.color);
label.position.y = 6.3;
g.add(label);
g.userData = { box: box, ring: ring, phase: rnd() * Math.PI * 2, dest: dest };
world.add(g);
items.push(g);
});
}
/* =========================================================
主循环
========================================================= */
let lastFrame = 0;
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
let dt = (now - lastFrame) / 1000;
lastFrame = now;
if (!isFinite(dt) || dt <= 0) dt = 1 / 60;
dt = Math.min(dt, 0.05);
try {
update(dt, now);
} catch (err) {
console.error(err);
}
renderer.render(scene, camera);
}
function update(dt, now) {
if (!track) return;
if (state === 'countdown') {
updateCountdown(now);
updateCamera(dt);
updateItemVisuals(dt, now);
updateNitroUI();
updateMinimap();
return;
}
if (state === 'race') {
updatePhysics(dt, now);
updateProgress();
updateItemInteraction(now);
updateHUD(now);
}
updateItemVisuals(dt, now);
updateNitroUI();
updateCamera(dt);
updateMinimap();
}
function updateCountdown(now) {
const e = (now - countdownStart) / 1000;
if (e < 3) {
const num = 3 - Math.floor(e);
const frac = e - Math.floor(e);
countdownEl.style.display = 'block';
countdownEl.textContent = String(num);
countdownEl.style.color = num === 1 ? '#ffe45e' : '#ffffff';
countdownEl.style.transform = 'translate(-50%,-50%) scale(' + (1.35 - frac * 0.35) + ')';
countdownEl.style.opacity = String(1 - frac * 0.25);
} else if (e < 3.85) {
countdownEl.style.display = 'block';
countdownEl.textContent = 'GO!';
countdownEl.style.color = '#ffe45e';
countdownEl.style.transform = 'translate(-50%,-50%) scale(1.15)';
countdownEl.style.opacity = '1';
} else {
countdownEl.style.display = 'none';
state = 'race';
raceStart = now;
}
}
function updatePhysics(dt, now) {
const step = Math.min(dt * 60, 2.2);
const boostingNow = boosting && now < boostEnd;
if (boosting && !boostingNow) endBoost();
const maxSpeed = boostingNow ? 1.72 : 1.05;
if (keys.w) speed += 0.021 * step;
if (keys.s) speed -= 0.05 * step;
if (boostingNow) speed += 0.075 * step;
const drag = offRoad ? 0.945 : 0.9915;
speed *= Math.pow(drag, step);
if (offRoad && speed > 0.62 && !boostingNow) speed -= (speed - 0.62) * 0.12 * step;
speed = Math.max(-0.45, Math.min(1.78, speed));
if (speed > maxSpeed) speed += (maxSpeed - speed) * Math.min(1, 0.035 * step);
if (Math.abs(speed) < 0.004) speed = 0;
/* 氮气回充:在赛道上跑动蓄能 */
if (!boostingNow) {
nitro = Math.min(NITRO_MAX, nitro + (offRoad ? 3.2 : 9) * dt);
}
/* 尾焰 */
flames.forEach(function (f) {
f.visible = boostingNow;
if (boostingNow) {
const k = 0.75 + Math.random() * 0.75;
f.scale.set(0.9 + Math.random() * 0.35, k, 0.9 + Math.random() * 0.35);
}
});
const steer = keys.a ? 1 : (keys.d ? -1 : 0);
const grip = Math.min(1, Math.abs(speed) / 0.28);
const dir = speed >= 0 ? 1 : -1;
car.rotation.y += steer * 0.048 * step * grip * dir;
car.translateZ(-speed * step);
/* 简易树干碰撞,避免穿模 */
for (let i = 0; i < treeColliders.length; i++) {
const t = treeColliders[i];
const dx = car.position.x - t.x;
const dz = car.position.z - t.z;
const d = Math.sqrt(dx * dx + dz * dz);
const minD = t.r + 1.15;
if (d < minD && d > 0.0001) {
const push = minD - d;
car.position.x += (dx / d) * push;
car.position.z += (dz / d) * push;
speed *= 0.7;
}
}
const targetRoll = -steer * 0.10 * Math.min(1, Math.abs(speed));
carBody.rotation.z += (targetRoll - carBody.rotation.z) * Math.min(1, dt * 8);
carBody.rotation.x = -speed * 0.02;
carBody.position.y = Math.sin(performance.now() * 0.018) * 0.012;
wheels.forEach(function (w) {
w.spinner.rotation.x += speed * step * 3.0;
if (w.isFront) w.pivot.rotation.y = steer * 0.34;
});
}
function nearestSampleIndex() {
const samples = track.samples;
const N = samples.length;
let best = lastIdx;
let bestD = Infinity;
for (let k = -50; k <= 50; k++) {
const i = (lastIdx + k + N) % N;
const dx = samples[i].p.x - car.position.x;
const dz = samples[i].p.z - car.position.z;
const d = dx * dx + dz * dz;
if (d < bestD) { bestD = d; best = i; }
}
/* 冲出赛道太远时做一次全局兜底搜索 */
if (bestD > 2500) {
for (let i = 0; i < N; i += 3) {
const dx = samples[i].p.x - car.position.x;
const dz = samples[i].p.z - car.position.z;
const d = dx * dx + dz * dz;
if (d < bestD) { bestD = d; best = i; }
}
}
return { index: best, dist: Math.sqrt(bestD) };
}
function updateProgress() {
const N = track.samples.length;
const res = nearestSampleIndex();
const idx = res.index;
if (idx !== lastIdx) {
const u = idx / N;
let delta = u - lastU;
if (delta > 0.5) delta -= 1;
if (delta < -0.5) delta += 1;
progress += delta;
lastU = u;
lastIdx = idx;
}
offRoad = res.dist > CONFIG.roadHalfWidth + 0.9;
while (progress >= lapsDone + 1) {
lapsDone++;
if (lapsDone >= CONFIG.lapsPerLevel) {
finishLevel();
return;
}
lapEl.textContent = (lapsDone + 1) + '/' + CONFIG.lapsPerLevel;
showToast('第 ' + lapsDone + ' 圈完成!最后一圈冲刺');
}
}
function updateCamera(dt) {
const offset = new THREE.Vector3(0, 6.6, 13.2);
offset.applyQuaternion(car.quaternion);
const desired = offset.add(car.position);
const k = 1 - Math.pow(0.0008, dt);
camera.position.lerp(desired, k);
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(car.quaternion);
const look = car.position.clone().add(new THREE.Vector3(0, 1.5, 0)).addScaledVector(forward, 6.5);
camera.lookAt(look);
const targetFov = 62 + Math.min(10, Math.abs(speed) * 9);
if (Math.abs(camera.fov - targetFov) > 0.05) {
camera.fov += (targetFov - camera.fov) * Math.min(1, dt * 4);
camera.updateProjectionMatrix();
}
}
/* =========================================================
道具交互
========================================================= */
function updateItemVisuals(dt, now) {
const t = now * 0.001;
items.forEach(function (it) {
const d = it.userData;
const bob = Math.sin(t * 2 + d.phase) * 0.26;
it.rotation.y += dt * 1.15;
d.box.position.y = 2.9 + bob;
d.ring.position.y = 2.72 + bob;
const target = (it === activeItem) ? 1.16 : 1.0;
const cur = d.box.scale.x;
const next = cur + (target - cur) * Math.min(1, dt * 10);
d.box.scale.setScalar(next);
});
}
function updateItemInteraction() {
if (items.length === 0) return;
let best = null;
let bestD = Infinity;
items.forEach(function (it) {
const dx = it.position.x - car.position.x;
const dz = it.position.z - car.position.z;
const d = Math.sqrt(dx * dx + dz * dz);
if (d < bestD) { bestD = d; best = it; }
});
if (bestD < 7) {
if (activeItem !== best) {
activeItem = best;
showPrompt(best.userData.dest);
}
} else if (activeItem) {
activeItem = null;
hidePrompt();
}
}
function showPrompt(dest) {
targetNameEl.textContent = dest.text;
promptEl.style.setProperty('--accent', dest.color);
promptIconEl.textContent = '🎁';
promptEl.classList.add('show');
}
function hidePrompt() {
promptEl.classList.remove('show');
}
function openItem() {
if (!activeItem || state !== 'race' || navigating) return;
navigating = true;
const url = activeItem.userData.dest.url;
hidePrompt();
document.body.style.transition = 'opacity 0.7s ease';
document.body.style.opacity = '0';
setTimeout(function () { window.location.href = url; }, 700);
}
/* =========================================================
氮气加速
========================================================= */
function tryBoost() {
if (state !== 'race' || boosting || navigating) return;
if (nitro < NITRO_COST) {
showToast('氮气不足,继续跑动蓄能');
return;
}
nitro -= NITRO_COST;
boosting = true;
boostEnd = performance.now() + 1500;
document.body.classList.add('boosting');
showToast('氮气加速!');
updateNitroUI();
}
function endBoost() {
boosting = false;
document.body.classList.remove('boosting');
flames.forEach(function (f) { f.visible = false; });
}
function updateNitroUI() {
const pct = Math.max(0, Math.min(NITRO_MAX, nitro));
nitroBtnEl.style.setProperty('--p', pct.toFixed(1) + '%');
nitroPctEl.textContent = Math.round(pct) + '%';
const ready = pct >= NITRO_COST && state === 'race' && !boosting;
nitroBtnEl.classList.toggle('ready', ready);
nitroBtnEl.classList.toggle('spent', pct < NITRO_COST || state !== 'race');
}
/* =========================================================
HUD
========================================================= */
function updateHUD(now) {
raceTime = (now - raceStart) / 1000;
timeEl.textContent = raceTime.toFixed(1);
const kmh = Math.round(Math.abs(speed) / 1.05 * 180);
speedNumEl.textContent = String(kmh);
speedBarEl.style.width = Math.min(100, Math.abs(speed) / 1.05 * 100).toFixed(0) + '%';
}
function showToast(text) {
toastEl.textContent = text;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 1700);
}
function hideToast() {
toastEl.classList.remove('show');
}
/* =========================================================
小地图
========================================================= */
function updateMinimap() {
if (!mmCtx || !track) return;
const ctx = mmCtx;
const W = 352, C = 176, R = 168;
const s = 158 / (track.maxR + 22);
ctx.clearRect(0, 0, W, W);
ctx.save();
ctx.beginPath();
ctx.arc(C, C, R, 0, Math.PI * 2);
ctx.clip();
const grd = ctx.createRadialGradient(C, C, 12, C, C, R);
grd.addColorStop(0, '#f6fcff');
grd.addColorStop(1, '#dceeff');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, W, W);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
track.samples.forEach(function (sm, i) {
const x = C + sm.p.x * s;
const y = C + sm.p.z * s;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 24;
ctx.stroke();
ctx.strokeStyle = palette.road;
ctx.lineWidth = 15;
ctx.stroke();
/* 起点 */
const sp = track.samples[0];
ctx.beginPath();
ctx.arc(C + sp.p.x * s, C + sp.p.z * s, 6, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = '#16202b';
ctx.stroke();
/* 道具 */
items.forEach(function (it) {
const x = C + it.position.x * s;
const y = C + it.position.z * s;
const isActive = (it === activeItem);
ctx.beginPath();
ctx.arc(x, y, isActive ? 9 : 6.5, 0, Math.PI * 2);
ctx.fillStyle = it.userData.dest.color;
ctx.fill();
ctx.lineWidth = 3.5;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
});
/* 车 */
const px = C + car.position.x * s;
const py = C + car.position.z * s;
const fx = -Math.sin(car.rotation.y);
const fz = -Math.cos(car.rotation.y);
const px2 = -fz, py2 = fx;
ctx.beginPath();
ctx.moveTo(px + fx * 15, py + fz * 15);
ctx.lineTo(px - fx * 9 + px2 * 9, py - fz * 9 + py2 * 9);
ctx.lineTo(px - fx * 4, py - fz * 4);
ctx.lineTo(px - fx * 9 - px2 * 9, py - fz * 9 - py2 * 9);
ctx.closePath();
ctx.fillStyle = '#ff4d3f';
ctx.fill();
ctx.lineWidth = 3.5;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.restore();
}
/* =========================================================
过关 / 输入 / 自适应
========================================================= */
function finishLevel() {
if (state !== 'race') return;
state = 'levelup';
raceTime = (performance.now() - raceStart) / 1000;
speed = 0;
activeItem = null;
hidePrompt();
luLevelEl.textContent = String(level);
luTimeEl.textContent = raceTime.toFixed(1);
luNextEl.textContent = String(level + 1);
levelupEl.classList.add('show');
setTimeout(function () {
levelupEl.classList.remove('show');
buildLevel(level + 1);
}, 3000);
}
function onKeyDown(e) {
const k = e.key.toLowerCase();
if (k === 'w' || k === 'a' || k === 's' || k === 'd') {
keys[k] = true;
e.preventDefault();
}
if ((e.key === 'Enter') && activeItem && state === 'race') {
e.preventDefault();
openItem();
return;
}
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
tryBoost();
}
}
function onKeyUp(e) {
const k = e.key.toLowerCase();
if (k === 'w' || k === 'a' || k === 's' || k === 'd') {
keys[k] = false;
e.preventDefault();
}
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
</script>
</body>
</html>