<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>SKY RACE — 3D Low-Poly Edition (WebRTC P2P Multiplayer)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Chakra+Petch:ital,wght@0,600;0,700;1,700&display=swap" rel="stylesheet">
<!-- PeerJS 1.5.5 for Zero-Server Internet WebRTC Multiplayer -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/peerjs/1.5.5/peerjs.min.js"></script>
<style>
:root {
--navy-dark: #0c1738;
--navy-panel: rgba(13, 25, 62, 0.88);
--border-light: #7ccbff;
--gold: #ffcc29;
--red-btn: #e82634;
--blue-btn: #1858d6;
--teal-btn: #147a68;
--purple-btn: #5928b5;
--text-white: #f8fbff;
--font-pixel: 'Press Start 2P', monospace;
--font-hud: 'Chakra Petch', sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
html, body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: #060b18;
font-family: var(--font-pixel);
color: var(--text-white);
touch-action: none;
}
#game-wrapper {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
#three-container {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
}
#ui-layer {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.interactive { pointer-events: auto; }
.screen-overlay {
position: absolute;
inset: 0;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
pointer-events: none;
}
.screen-overlay.active { display: flex; }
.retro-panel {
background: var(--navy-panel);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 2px solid var(--border-light);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.65), inset 0 0 18px rgba(68, 164, 255, 0.22);
border-radius: 8px;
padding: 16px;
pointer-events: auto;
}
.arcade-btn {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
width: 310px;
height: 50px;
padding: 0 18px;
border: 2px solid #ffffff;
border-radius: 8px;
font-family: var(--font-pixel);
font-size: 13px;
color: #ffffff;
text-shadow: 2px 2px 0 #09122c;
cursor: pointer;
pointer-events: auto;
transition: transform 0.08s ease, filter 0.12s ease, box-shadow 0.12s ease;
box-shadow: 0 5px 0 #09122c, 0 8px 18px rgba(0,0,0,0.55);
margin-bottom: 10px;
gap: 14px;
}
.arcade-btn:hover, .arcade-btn.focused {
filter: brightness(1.16);
transform: translateY(-2px) scale(1.01);
border-color: var(--gold);
box-shadow: 0 7px 0 #09122c, 0 0 16px rgba(255, 204, 41, 0.65);
}
.arcade-btn:active {
transform: translateY(3px);
box-shadow: 0 2px 0 #09122c;
}
.arcade-btn.red { background: linear-gradient(180deg, #ff4b59 0%, #d41826 55%, #9c0c18 100%); }
.arcade-btn.blue { background: linear-gradient(180deg, #3d8bff 0%, #1552c9 55%, #0c358a 100%); }
.arcade-btn.teal { background: linear-gradient(180deg, #27b89d 0%, #107864 55%, #094f41 100%); }
.arcade-btn.purple { background: linear-gradient(180deg, #8b52ff 0%, #5622b5 55%, #381182 100%); }
.arcade-btn.gold {
background: linear-gradient(180deg, #ffe055 0%, #e69d00 60%, #a86800 100%);
color: #09122c;
text-shadow: 1px 1px 0 #fff5b8;
}
.arcade-btn.sm {
width: auto;
height: 38px;
padding: 0 14px;
font-size: 10px;
margin-bottom: 0;
}
.logo-header {
text-align: center;
margin-bottom: 18px;
pointer-events: none;
filter: drop-shadow(0 6px 12px rgba(0,0,0,0.75));
}
.logo-wings {
display: inline-flex;
align-items: center;
gap: 12px;
background: linear-gradient(180deg, rgba(14,27,68,0.92), rgba(8,15,38,0.95));
border: 3px solid #8ad4ff;
border-radius: 12px;
padding: 10px 26px;
box-shadow: 0 6px 0 #071029;
}
.logo-sky {
font-size: clamp(22px, 4vw, 36px);
background: linear-gradient(180deg, #ffffff 0%, #89d4ff 55%, #2e78db 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(3px 3px 0 #091436);
}
.logo-race {
font-size: clamp(22px, 4vw, 36px);
background: linear-gradient(180deg, #fff066 0%, #ff9914 55%, #db2929 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(3px 3px 0 #091436);
}
.logo-sub {
margin-top: 6px;
font-size: 9px;
color: #a8e0ff;
letter-spacing: 2px;
text-shadow: 1px 1px 0 #000;
}
.top-nav-bar {
position: absolute;
top: 14px;
left: 16px;
right: 16px;
display: flex;
justify-content: space-between;
align-items: center;
pointer-events: none;
}
.screen-title-banner {
background: linear-gradient(180deg, #1b3882 0%, #0e1e4d 100%);
border: 2px solid #84d2ff;
border-radius: 8px;
padding: 8px 22px;
font-size: clamp(12px, 2vw, 18px);
color: #ffffff;
text-shadow: 2px 2px 0 #071029;
box-shadow: 0 4px 0 #071029;
}
.hangar-stats-panel {
position: absolute;
top: 68px;
right: 20px;
width: min(320px, 42vw);
z-index: 15;
}
.plane-title {
font-size: 14px;
color: #ffffff;
margin-bottom: 4px;
}
.plane-class {
font-size: 9px;
color: #63c5ff;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(124, 203, 255, 0.35);
}
.stat-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 7px;
font-size: 8px;
}
.stat-segments {
display: flex;
gap: 3px;
width: 135px;
background: #07112b;
padding: 3px;
border: 1px solid #3569b8;
border-radius: 4px;
}
.stat-seg {
flex: 1;
height: 8px;
background: #162952;
transform: skewX(-18deg);
}
.stat-seg.filled {
background: linear-gradient(180deg, #ffe247 0%, #ff9900 100%);
box-shadow: 0 0 6px rgba(255, 204, 41, 0.6);
}
.plane-desc {
margin-top: 8px;
font-family: var(--font-hud);
font-size: 13px;
line-height: 1.35;
color: #dbeeff;
}
.plane-grid-dock {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
width: min(960px, 96vw);
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
pointer-events: auto;
}
.plane-card {
background: linear-gradient(180deg, rgba(22, 43, 98, 0.92) 0%, rgba(10, 21, 52, 0.96) 100%);
border: 2px solid #4888d6;
border-radius: 6px;
padding: 7px 8px;
cursor: pointer;
position: relative;
transition: transform 0.1s ease, border-color 0.1s ease;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 62px;
}
.plane-card:hover {
transform: translateY(-2px);
border-color: #9be0ff;
}
.plane-card.selected {
border-color: var(--gold);
box-shadow: 0 0 14px rgba(255, 204, 41, 0.65), inset 0 0 10px rgba(255, 204, 41, 0.25);
}
.plane-card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 8px;
color: #9ecfff;
}
.plane-swatch {
width: 28px;
height: 12px;
border-radius: 3px;
border: 1px solid #fff;
}
.plane-card-name {
font-size: 8px;
color: #ffffff;
margin-top: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plane-check {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 4px;
background: var(--gold);
color: #09122c;
font-size: 10px;
display: none;
align-items: center;
justify-content: center;
font-weight: bold;
border: 1px solid #fff;
}
.plane-card.selected .plane-check { display: flex; }
.routes-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
width: min(980px, 95vw);
max-height: 64vh;
overflow-y: auto;
padding: 6px;
pointer-events: auto;
}
.route-card {
background: linear-gradient(180deg, rgba(20, 39, 90, 0.92), rgba(10, 20, 48, 0.96));
border: 2px solid #4d8fe0;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: transform 0.1s ease, border-color 0.1s ease;
}
.route-card:hover { transform: translateY(-2px); border-color: #9be0ff; }
.route-card.selected {
border-color: var(--gold);
box-shadow: 0 0 16px rgba(255, 204, 41, 0.55);
}
.route-title { font-size: 10px; color: var(--gold); margin-bottom: 5px; }
.route-sub { font-family: var(--font-hud); font-size: 12px; color: #cce6ff; margin-bottom: 8px; }
.route-badges { display: flex; gap: 6px; font-size: 7px; color: #8ad4ff; flex-wrap: wrap; }
.route-badge { background: rgba(0,0,0,0.4); padding: 3px 6px; border-radius: 4px; border: 1px solid #3569b8; }
#hud-screen {
justify-content: space-between;
padding: 12px 16px;
}
.hud-top-bar {
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-start;
pointer-events: none;
}
.hud-box {
background: linear-gradient(180deg, rgba(16, 33, 78, 0.9) 0%, rgba(9, 19, 46, 0.94) 100%);
border: 2px solid #84d2ff;
border-radius: 8px;
padding: 8px 14px;
box-shadow: 0 4px 0 #060d24, 0 6px 16px rgba(0,0,0,0.5);
pointer-events: auto;
}
.hud-lap { font-size: clamp(11px, 1.8vw, 15px); color: #ffffff; }
.hud-lap span { color: var(--gold); }
.hud-timer { font-size: clamp(12px, 2vw, 16px); color: #ffffff; letter-spacing: 1px; }
.hud-pos { font-size: clamp(16px, 2.6vw, 24px); color: var(--gold); }
.hud-pos small { font-size: 12px; color: #ffffff; }
.hud-bottom-bar {
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
pointer-events: none;
}
.minimap-box {
width: 132px;
height: 150px;
padding: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#minimap-canvas {
width: 116px;
height: 132px;
background: rgba(7, 16, 38, 0.85);
border-radius: 4px;
}
.speedo-box {
width: 215px;
padding: 10px 14px;
text-align: right;
}
.speed-bars {
display: flex;
gap: 3px;
height: 14px;
margin-bottom: 6px;
align-items: flex-end;
}
.speed-bar-seg {
flex: 1;
height: 100%;
background: #1b2d59;
transform: skewX(-16deg);
border-radius: 1px;
}
.speed-readout {
font-size: 20px;
color: #ffffff;
text-shadow: 2px 2px 0 #060d24;
}
.speed-unit { font-size: 9px; color: #8ad4ff; }
.boost-meter-wrap {
margin-top: 6px;
background: #07112b;
border: 1px solid #4888d6;
border-radius: 4px;
height: 10px;
overflow: hidden;
position: relative;
}
.boost-meter-fill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #00e1ff 0%, #ffcc29 65%, #ff3b2f 100%);
transition: width 0.06s linear;
}
#countdown-banner {
position: absolute;
top: 36%;
left: 50%;
transform: translate(-50%, -50%);
font-size: clamp(32px, 6vw, 64px);
color: var(--gold);
text-shadow: 4px 4px 0 #09122c, 0 0 28px rgba(255, 204, 41, 0.85);
pointer-events: none;
text-align: center;
}
#checkpoint-toast {
position: absolute;
top: 22%;
left: 50%;
transform: translateX(-50%);
font-size: 13px;
color: #5eff9c;
text-shadow: 2px 2px 0 #060d24;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.touch-controls {
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
display: none;
gap: 16px;
align-items: center;
pointer-events: auto;
z-index: 25;
}
@media (pointer: coarse), (max-width: 900px) {
.touch-controls { display: flex; }
.hangar-stats-panel { top: 58px; right: 10px; width: 250px; padding: 10px; }
.plane-grid-dock { grid-template-columns: repeat(5, 1fr); bottom: 6px; }
}
.touch-pad-btn {
width: 58px;
height: 58px;
border-radius: 12px;
background: rgba(16, 35, 84, 0.82);
border: 2px solid #84d2ff;
color: #fff;
font-family: var(--font-pixel);
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 0 #060d24;
}
.touch-pad-btn:active {
background: var(--gold);
color: #09122c;
transform: translateY(2px);
}
.touch-boost-btn {
width: 82px;
height: 58px;
background: linear-gradient(180deg, #ff512f, #dd2476);
border: 2px solid #fff;
border-radius: 12px;
font-family: var(--font-pixel);
font-size: 10px;
color: #fff;
}
.lobby-layout {
display: grid;
grid-template-columns: 1.15fr 1fr;
gap: 14px;
width: min(920px, 95vw);
margin-top: 44px;
}
@media (max-width: 740px) {
.lobby-layout { grid-template-columns: 1fr; }
}
.lobby-input {
background: #07112b;
border: 2px solid #4d8fe0;
border-radius: 6px;
color: #fff;
padding: 8px 10px;
font-family: var(--font-pixel);
font-size: 10px;
width: 100%;
}
.player-slot {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(7, 17, 43, 0.78);
border: 1px solid #3569b8;
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 6px;
font-size: 9px;
}
.status-pill {
padding: 3px 7px;
border-radius: 4px;
font-size: 8px;
}
.status-pill.ready { background: #168a4c; color: #fff; }
.status-pill.wait { background: #7a5af8; color: #fff; }
.turntable-hint {
position: absolute;
bottom: 155px;
left: 50%;
transform: translateX(-50%);
font-size: 9px;
color: #a8e0ff;
background: rgba(8, 17, 42, 0.78);
padding: 5px 12px;
border-radius: 20px;
border: 1px solid #4888d6;
pointer-events: none;
}
.top-utility-pill {
position: fixed;
top: 10px;
left: 12px;
z-index: 40;
display: flex;
gap: 6px;
pointer-events: auto;
}
.util-chip {
background: rgba(10, 20, 48, 0.82);
border: 1px solid #5caeff;
color: #bfe4ff;
font-family: var(--font-pixel);
font-size: 8px;
padding: 5px 8px;
border-radius: 5px;
cursor: pointer;
}
.util-chip:hover { border-color: var(--gold); color: #fff; }
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
}
}
</script>
</head>
<body>
<div id="game-wrapper">
<!-- 3D LOW-POLY THREE.JS VIEWPORT -->
<div id="three-container"></div>
<!-- QUICK UTILITY CHIPS -->
<div class="top-utility-pill" id="top-util-bar">
<button class="util-chip" id="btn-mute-quick" title="Toggle Audio">🔊 AUDIO: ON</button>
<button class="util-chip" id="btn-cam-mode" title="Cycle 3D Camera View">🎥 CAM: CHASE</button>
<button class="util-chip" id="btn-download-html" title="Download Standalone HTML Game File">⬇ SAVE HTML</button>
<button class="util-chip" id="btn-self-test" title="Run Automated Verification Suite">✔ VERIFY</button>
</div>
<!-- UI OVERLAY LAYER -->
<div id="ui-layer">
<!-- 1. MAIN MENU SCREEN -->
<section id="screen-menu" class="screen-overlay active">
<div class="logo-header">
<div class="logo-wings">
<span class="logo-sky">SKY</span>
<span style="font-size:22px;">✈️</span>
<span class="logo-race">RACE</span>
</div>
<div class="logo-sub">3D LOW-POLY WORLD GRAND PRIX • WEBRTC P2P EDITION</div>
</div>
<nav aria-label="Main Menu">
<button class="arcade-btn red" data-action="nav-singleplayer">
<span>✈️</span> <span>SINGLEPLAYER</span>
</button>
<button class="arcade-btn blue" data-action="nav-multiplayer">
<span>🌐</span> <span>MULTIPLAYER</span>
</button>
<button class="arcade-btn teal" data-action="nav-planes">
<span>🛠️</span> <span>SELECT PLANES</span>
</button>
<button class="arcade-btn purple" data-action="nav-options">
<span>⚙️</span> <span>OPTIONS</span>
</button>
</nav>
</section>
<!-- 2. SELECT PLANES (3D HANGAR TURNTABLE) -->
<section id="screen-planes" class="screen-overlay">
<div class="top-nav-bar">
<button class="arcade-btn blue sm interactive" data-action="planes-back">⬅ BACK</button>
<div class="screen-title-banner">SELECT PLANES</div>
<button class="arcade-btn gold sm interactive" data-action="planes-confirm" id="btn-plane-confirm">CONFIRM ➔</button>
</div>
<aside class="retro-panel hangar-stats-panel">
<div class="plane-title" id="hangar-plane-name">RED ARROW</div>
<div class="plane-class" id="hangar-plane-class">Racer Class • #01</div>
<div class="stat-row">
<span>SPEED</span>
<div class="stat-segments" id="stat-speed"></div>
</div>
<div class="stat-row">
<span>ACCELERATION</span>
<div class="stat-segments" id="stat-accel"></div>
</div>
<div class="stat-row">
<span>HANDLING</span>
<div class="stat-segments" id="stat-handling"></div>
</div>
<div class="stat-row">
<span>BOOST</span>
<div class="stat-segments" id="stat-boost"></div>
</div>
<p class="plane-desc" id="hangar-plane-desc">
Balanced handling and dependable performance across all circuits.
</p>
</aside>
<div class="turntable-hint">🖱️ DRAG OR SWIPE TO ROTATE 3D AIRCRAFT</div>
<div class="plane-grid-dock" id="plane-cards-container"></div>
</section>
<!-- 3. ROUTE SELECTION SCREEN (7 WORLD ROUTES) -->
<section id="screen-routes" class="screen-overlay">
<div class="top-nav-bar">
<button class="arcade-btn blue sm interactive" data-action="routes-back">⬅ BACK</button>
<div class="screen-title-banner">SELECT WORLD ROUTE (7 CIRCUITS)</div>
<div style="display:flex; gap:8px;" class="interactive">
<button class="arcade-btn teal sm" data-action="routes-change-plane">✈️ PLANE</button>
<button class="arcade-btn gold sm" data-action="routes-launch">LAUNCH RACE ➔</button>
</div>
</div>
<div class="routes-grid" id="routes-grid-container"></div>
</section>
<!-- 4. ONLINE MULTIPLAYER LOBBY -->
<section id="screen-multiplayer" class="screen-overlay">
<div class="top-nav-bar">
<button class="arcade-btn blue sm interactive" data-action="mp-back">⬅ MENU</button>
<div class="screen-title-banner">ONLINE MULTIPLAYER LOBBY</div>
<button class="arcade-btn teal sm interactive" data-action="mp-change-plane">✈️ CHANGE PLANE</button>
</div>
<div class="lobby-layout">
<div class="retro-panel">
<div style="font-size:11px; color:var(--gold); margin-bottom:10px;">CONNECTION & MATCHMAKING</div>
<div style="margin-bottom:8px;">
<label style="font-size:8px; color:#9ecfff; display:block; margin-bottom:4px;">PILOT CALLSIGN & NETWORK MODE</label>
<div style="display:flex; gap:6px;">
<input id="mp-pilot-name" class="lobby-input" value="ACE-01" maxlength="12" style="width:42%;" />
<select id="mp-mode-select" class="lobby-input" style="width:58%;">
<option value="webrtc">Internet P2P (WebRTC / Zero-Server)</option>
<option value="cloud">Same-PC / Multi-Tab Sync</option>
<option value="colyseus">Colyseus WebSocket Server</option>
</select>
</div>
</div>
<div id="colyseus-url-row" style="margin-bottom:8px; display:none;">
<label style="font-size:8px; color:#9ecfff; display:block; margin-bottom:4px;">COLYSEUS SERVER ENDPOINT</label>
<input id="mp-server-url" class="lobby-input" value="ws://localhost:2567" />
</div>
<div style="display:flex; gap:6px; margin-bottom:8px; flex-wrap:wrap;">
<button class="arcade-btn red sm" data-action="mp-quick">⚡ QUICK MATCH</button>
<button class="arcade-btn blue sm" data-action="mp-create">➕ HOST ROOM</button>
<button class="arcade-btn gold sm" data-action="mp-copy-invite" title="Copy direct URL link for friends">📋 COPY INVITE LINK</button>
</div>
<div style="display:flex; gap:6px; margin-bottom:8px;">
<input id="mp-room-code-input" class="lobby-input" placeholder="ROOM CODE (E.G. SKY3D)" maxlength="6" style="text-transform:uppercase;" />
<button class="arcade-btn teal sm" data-action="mp-join">JOIN BY CODE</button>
</div>
<div style="margin-bottom:8px;">
<label style="font-size:8px; color:#9ecfff; display:block; margin-bottom:4px;">HOST CIRCUIT SELECTION</label>
<select id="mp-route-select" class="lobby-input"></select>
</div>
<div id="mp-status-log" style="font-family:var(--font-hud); font-size:12px; color:#74f7a5; min-height:20px;">
Ready! Click HOST ROOM and share your Room Code or Invite Link with friends anywhere on the internet.
</div>
</div>
<div class="retro-panel" style="display:flex; flex-direction:column; justify-content:space-between;">
<div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<span style="font-size:11px; color:var(--gold);">ROOM ROSTER (MAX 6)</span>
<span id="mp-room-badge" style="font-size:10px; color:#8ad4ff;">CODE: ----</span>
</div>
<div id="mp-roster-list"></div>
</div>
<div style="display:flex; gap:8px; justify-content:space-between; margin-top:10px; flex-wrap:wrap;">
<button class="arcade-btn teal sm" data-action="mp-toggle-ready" id="btn-mp-ready">✓ READY UP</button>
<button class="arcade-btn gold sm" data-action="mp-start-race" id="btn-mp-start">🏁 START 3D RACE</button>
<button class="arcade-btn purple sm" data-action="mp-export-server" title="Download Colyseus Server Room Source">⬇ SERVER .TS</button>
</div>
</div>
</div>
</section>
<!-- 5. ACTIVE 3D RACE HUD -->
<section id="hud-screen" class="screen-overlay">
<div class="hud-top-bar">
<div class="hud-box hud-lap">
LAP <span id="hud-lap-val">1/3</span>
</div>
<div class="hud-box hud-timer">
⏱ <span id="hud-time-val">00:00.00</span>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<div class="hud-box hud-pos" id="hud-pos-val">
1st <small>/6</small>
</div>
<button class="arcade-btn blue sm interactive" data-action="race-pause" title="Pause (ESC or P)">❚❚</button>
</div>
</div>
<div id="checkpoint-toast">CHECKPOINT PASSED! +BOOST</div>
<div id="countdown-banner"></div>
<!-- Mobile / Tablet On-Screen Controls -->
<div class="touch-controls">
<button class="touch-pad-btn" id="touch-left">◀</button>
<button class="touch-pad-btn" id="touch-up">▲</button>
<button class="touch-pad-btn" id="touch-down">▼</button>
<button class="touch-pad-btn" id="touch-right">▶</button>
<button class="touch-boost-btn" id="touch-boost">🔥 BOOST</button>
</div>
<div class="hud-bottom-bar">
<div class="hud-box minimap-box">
<canvas id="minimap-canvas" width="116" height="132"></canvas>
</div>
<div class="hud-box speedo-box">
<div class="speed-bars" id="hud-speed-bars"></div>
<div>
<span class="speed-readout" id="hud-speed-num">420</span>
<span class="speed-unit">KM/H</span>
</div>
<div class="boost-meter-wrap">
<div class="boost-meter-fill" id="hud-boost-fill"></div>
</div>
</div>
</div>
</section>
<!-- 6. PAUSE OVERLAY -->
<section id="screen-pause" class="screen-overlay" style="background:rgba(5,10,25,0.65);">
<div class="retro-panel" style="text-align:center; width:340px;">
<div style="font-size:16px; color:var(--gold); margin-bottom:18px;">RACE PAUSED</div>
<button class="arcade-btn gold" style="width:100%;" data-action="pause-resume">▶ RESUME RACE</button>
<button class="arcade-btn teal" style="width:100%;" data-action="pause-restart">↻ RESTART CIRCUIT</button>
<button class="arcade-btn red" style="width:100%;" data-action="pause-quit">⬅ EXIT TO MENU</button>
</div>
</section>
<!-- 7. RACE RESULTS OVERLAY -->
<section id="screen-results" class="screen-overlay" style="background:rgba(5,10,25,0.72);">
<div class="retro-panel" style="width:min(620px, 94vw); text-align:center;">
<div id="results-headline" style="font-size:18px; color:var(--gold); margin-bottom:6px;">🏁 GRAND PRIX FINISHED!</div>
<div id="results-sub" style="font-family:var(--font-hud); font-size:14px; color:#a8e0ff; margin-bottom:14px;">Circuit Standings</div>
<div id="results-standings" style="margin-bottom:16px; text-align:left;"></div>
<div style="display:flex; gap:10px; justify-content:center; flex-wrap:wrap;">
<button class="arcade-btn gold sm" data-action="results-rematch">↻ RACE AGAIN</button>
<button class="arcade-btn teal sm" data-action="results-routes">🌍 CHANGE ROUTE</button>
<button class="arcade-btn blue sm" data-action="results-menu">🏠 MAIN MENU</button>
</div>
</div>
</section>
<!-- 8. OPTIONS & PERSISTENT SETTINGS OVERLAY -->
<section id="screen-options" class="screen-overlay">
<div class="retro-panel" style="width:min(540px, 94vw);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:14px;">
<span style="font-size:14px; color:var(--gold);">⚙️ OPTIONS & CONTROLS</span>
<button class="arcade-btn blue sm" data-action="options-close">✓ DONE</button>
</div>
<div style="margin-bottom:12px;">
<label style="font-size:9px; display:flex; justify-content:space-between; margin-bottom:4px;">
<span>16-BIT MUSIC VOLUME</span> <span id="lbl-opt-music">75%</span>
</label>
<input type="range" id="opt-music" min="0" max="100" value="75" style="width:100%;" />
</div>
<div style="margin-bottom:12px;">
<label style="font-size:9px; display:flex; justify-content:space-between; margin-bottom:4px;">
<span>ARCADE SFX & ENGINE VOLUME</span> <span id="lbl-opt-sfx">80%</span>
</label>
<input type="range" id="opt-sfx" min="0" max="100" value="80" style="width:100%;" />
</div>
<div style="margin-bottom:12px;">
<label style="font-size:9px; display:flex; justify-content:space-between; margin-bottom:4px;">
<span>3D STEERING SENSITIVITY</span> <span id="lbl-opt-sens">1.0x</span>
</label>
<input type="range" id="opt-sens" min="50" max="160" value="100" style="width:100%;" />
</div>
<div style="margin-bottom:14px; display:flex; justify-content:space-between; align-items:center;">
<span style="font-size:9px;">AI DIFFICULTY</span>
<select id="opt-diff" class="lobby-input" style="width:180px;">
<option value="0.9">ROOKIE (CASUAL)</option>
<option value="1.0" selected>ARCADE STANDARD</option>
<option value="1.12">ACE PILOT (PRO)</option>
</select>
</div>
<div style="background:rgba(0,0,0,0.35); border:1px solid #3569b8; border-radius:6px; padding:10px; font-family:var(--font-hud); font-size:13px; line-height:1.45; color:#cce6ff; margin-bottom:12px;">
<strong>FLIGHT CONTROLS:</strong><br/>
• <strong>Steer & Bank (3D Roll/Yaw):</strong> A / D or Left / Right Arrow<br/>
• <strong>Pitch Altitude (Climb / Dive):</strong> W / S or Up / Down Arrow<br/>
• <strong>Afterburner Boost:</strong> SPACEBAR or SHIFT<br/>
• <strong>Pause / Camera View:</strong> ESC or P / C Key
</div>
<div style="display:flex; justify-content:space-between;">
<button class="arcade-btn red sm" data-action="options-reset">RESET DEFAULTS</button>
<button class="arcade-btn teal sm" data-action="options-mute" id="btn-opt-mute">MUTE ALL: OFF</button>
</div>
</div>
</section>
</div>
</div>
<script type="module">
import * as THREE from 'three';
// ============================================================================
// 1. STRUCTURED DATA: ALL 10 AIRCRAFT & ALL 7 WORLD ROUTES
// ============================================================================
export const PLANES_DATA = [
{
id: 'red-arrow', num: '01', name: 'RED ARROW',
desc: 'Balanced handling and dependable performance.',
primary: 0xe6222e, secondary: 0xffffff, accent: 0xffcc29,
wingStyle: 'swept', tailStyle: 'single',
stats: { speed: 7, accel: 7, handling: 8, boost: 7 }
},
{
id: 'sky-wolf', num: '02', name: 'SKY WOLF',
desc: 'High top speed and aggressive acceleration.',
primary: 0x1660e8, secondary: 0xffffff, accent: 0x66d9ff,
wingStyle: 'forward', tailStyle: 'twin',
stats: { speed: 9, accel: 8, handling: 6, boost: 6 }
},
{
id: 'forest-fang', num: '03', name: 'FOREST FANG',
desc: 'Stable flight and strong handling.',
primary: 0x199c49, secondary: 0xe8f5e9, accent: 0xaeea00,
wingStyle: 'wide', tailStyle: 'single',
stats: { speed: 6, accel: 7, handling: 9, boost: 7 }
},
{
id: 'sun-flare', num: '04', name: 'SUN FLARE',
desc: 'Excellent acceleration.',
primary: 0xffb300, secondary: 0xfff8e1, accent: 0xff3d00,
wingStyle: 'tapered', tailStyle: 'vtail',
stats: { speed: 7, accel: 10, handling: 7, boost: 6 }
},
{
id: 'violet-storm', num: '05', name: 'VIOLET STORM',
desc: 'High boost performance.',
primary: 0x7b1fa2, secondary: 0xf3e5f5, accent: 0x00e5ff,
wingStyle: 'delta', tailStyle: 'twin',
stats: { speed: 7, accel: 6, handling: 6, boost: 10 }
},
{
id: 'shadow-rider', num: '06', name: 'SHADOW RIDER',
desc: 'Agile handling and quick directional changes.',
primary: 0x262936, secondary: 0xe53935, accent: 0xb0bec5,
wingStyle: 'forward', tailStyle: 'vtail',
stats: { speed: 7, accel: 7, handling: 10, boost: 6 }
},
{
id: 'ocean-blade', num: '07', name: 'OCEAN BLADE',
desc: 'Balanced speed and long-distance stability.',
primary: 0xf5f9ff, secondary: 0x0288d1, accent: 0xff6d00,
wingStyle: 'wide', tailStyle: 'single',
stats: { speed: 8, accel: 7, handling: 8, boost: 7 }
},
{
id: 'fire-hawk', num: '08', name: 'FIRE HAWK',
desc: 'High speed and powerful boost.',
primary: 0xff6d00, secondary: 0xfff3e0, accent: 0xd50000,
wingStyle: 'swept', tailStyle: 'twin',
stats: { speed: 9, accel: 6, handling: 5, boost: 9 }
},
{
id: 'tide-jet', num: '09', name: 'TIDE JET',
desc: 'Smooth steering and consistent acceleration.',
primary: 0x00bfa5, secondary: 0xe0f2f1, accent: 0xffea00,
wingStyle: 'pods', tailStyle: 'single',
stats: { speed: 6, accel: 8, handling: 9, boost: 7 }
},
{
id: 'pink-flash', num: '10', name: 'PINK FLASH',
desc: 'Lightweight handling and rapid acceleration.',
primary: 0xf50057, secondary: 0xfce4ec, accent: 0x00e5ff,
wingStyle: 'tapered', tailStyle: 'vtail',
stats: { speed: 7, accel: 9, handling: 9, boost: 6 }
}
];
export const ROUTES_DATA = [
{
id: 'miami',
name: 'ROUTE 1 — MIAMI, USA',
subtitle: 'Art Deco South Beach, Turquoise Bay & Palm Islands',
difficulty: 'EASY',
skyTop: 0x1b75ff, skyHorizon: 0x99ebff, waterColor: 0x00c2d1, groundColor: 0xf5deb3,
musicRoot: 261.63,
windForce: 0.15,
waypoints: [
[0, 26, 0], [180, 28, -280], [60, 34, -620], [-220, 26, -740],
[-420, 24, -460], [-360, 30, -140]
]
},
{
id: 'tokyo',
name: 'ROUTE 2 — TOKYO, JAPAN',
subtitle: 'Tokyo Tower, Neon Metropolis, Elevated Expressways & Mt. Fuji',
difficulty: 'MEDIUM',
skyTop: 0x0f1c4d, skyHorizon: 0x5c6bc0, waterColor: 0x15315c, groundColor: 0x263238,
musicRoot: 293.66,
windForce: 0.25,
waypoints: [
[0, 30, 0], [240, 38, -260], [160, 24, -580], [-140, 42, -780],
[-440, 32, -520], [-320, 26, -180]
]
},
{
id: 'rome',
name: 'ROUTE 3 — ROME, ITALY',
subtitle: 'The Colosseum, Ancient Aqueducts, Domes & Tiber River',
difficulty: 'MEDIUM',
skyTop: 0x2979ff, skyHorizon: 0xffe0b2, waterColor: 0x1e88e5, groundColor: 0xd7ccc8,
musicRoot: 246.94,
windForce: 0.2,
waypoints: [
[0, 28, 0], [210, 32, -310], [-20, 36, -640], [-290, 28, -680],
[-480, 34, -360], [-260, 26, -120]
]
},
{
id: 'shanghai',
name: 'ROUTE 4 — SHANGHAI, CHINA',
subtitle: 'Oriental Pearl Tower, Pudong Skyline & Huangpu Bridges',
difficulty: 'HARD',
skyTop: 0x102a5c, skyHorizon: 0x4dd0e1, waterColor: 0x0d47a1, groundColor: 0x37474f,
musicRoot: 329.63,
windForce: 0.35,
waypoints: [
[0, 32, 0], [280, 44, -240], [220, 26, -600], [-160, 46, -820],
[-500, 30, -540], [-380, 28, -200]
]
},
{
id: 'egypt',
name: 'ROUTE 5 — EGYPT (GIZA & NILE)',
subtitle: 'Great Pyramids of Giza, Great Sphinx & Golden Nile Delta',
difficulty: 'MEDIUM',
skyTop: 0x1565c0, skyHorizon: 0xffd54f, waterColor: 0x0277bd, groundColor: 0xfbc02d,
musicRoot: 220.0,
windForce: 0.4,
waypoints: [
[0, 28, 0], [230, 26, -300], [110, 38, -660], [-240, 32, -740],
[-460, 26, -420], [-290, 28, -140]
]
},
{
id: 'paris',
name: 'ROUTE 6 — PARIS, FRANCE',
subtitle: 'Eiffel Tower, Seine River Bridges & Haussmann Rooftops',
difficulty: 'EASY',
skyTop: 0x42a5f5, skyHorizon: 0xe3f2fd, waterColor: 0x29b6f6, groundColor: 0xcfd8dc,
musicRoot: 277.18,
windForce: 0.18,
waypoints: [
[0, 28, 0], [190, 34, -290], [40, 30, -610], [-250, 34, -710],
[-430, 28, -430], [-310, 26, -150]
]
},
{
id: 'brazil',
name: 'ROUTE 7 — RIO DE JANEIRO, BRAZIL',
subtitle: 'Christ the Redeemer, Sugarloaf Peaks & Guanabara Bay',
difficulty: 'ACE',
skyTop: 0x00838f, skyHorizon: 0xb2ff59, waterColor: 0x00acc1, groundColor: 0x2e7d32,
musicRoot: 349.23,
windForce: 0.32,
waypoints: [
[0, 32, 0], [260, 48, -270], [140, 26, -630], [-210, 50, -790],
[-490, 28, -480], [-340, 32, -160]
]
}
];
// ============================================================================
// 2. PERSISTENT SETTINGS MANAGER
// ============================================================================
const STORAGE_KEY = 'skyrace_3d_settings_v2';
const DEFAULT_SETTINGS = {
musicVolume: 75,
sfxVolume: 80,
muted: false,
sensitivity: 1.0,
aiDifficulty: 1.0,
selectedPlaneIndex: 0,
selectedRouteIndex: 0,
pilotName: 'ACE-' + Math.floor(10 + Math.random() * 89)
};
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings(settings) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
// Ignore storage blocked errors
}
}
const appSettings = loadSettings();
// ============================================================================
// 3. ORIGINAL 16-BIT CHIPTUNE WEB AUDIO SYNTHESIZER (PRESERVED RETRO IDENTITY)
// ============================================================================
class ChiptuneAudioEngine {
constructor() {
this.ctx = null;
this.musicTimer = null;
this.currentMode = 'none';
this.step = 0;
this.routeFreq = 261.63;
this.engineOsc = null;
this.engineGain = null;
}
ensureContext() {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) this.ctx = new AudioCtx();
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume().catch(() => {});
}
}
getMusicGain() {
if (appSettings.muted) return 0;
return (appSettings.musicVolume / 100) * 0.14;
}
getSfxGain() {
if (appSettings.muted) return 0;
return (appSettings.sfxVolume / 100) * 0.22;
}
playTone(freq, duration = 0.12, type = 'square', gainScale = 1.0, slideTo = null) {
this.ensureContext();
if (!this.ctx || appSettings.muted) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, now);
if (slideTo) {
osc.frequency.exponentialRampToValueAtTime(Math.max(40, slideTo), now + duration);
}
const vol = Math.max(0.0001, this.getSfxGain() * gainScale);
gain.gain.setValueAtTime(vol, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + duration + 0.02);
}
playUiClick() { this.playTone(660, 0.06, 'square', 0.6, 880); }
playUiConfirm() {
this.playTone(523.25, 0.08, 'square', 0.7);
setTimeout(() => this.playTone(783.99, 0.14, 'square', 0.8), 70);
}
playCountdownBeep(finalGo = false) {
this.playTone(finalGo ? 880 : 440, finalGo ? 0.32 : 0.15, 'square', 0.9);
}
playCheckpoint() {
this.playTone(587.33, 0.09, 'triangle', 0.85);
setTimeout(() => this.playTone(880, 0.16, 'square', 0.85), 80);
}
playBoostSound() {
this.playTone(180, 0.25, 'sawtooth', 0.8, 620);
}
playVictoryFanfare() {
const notes = [523.25, 659.25, 783.99, 1046.5];
notes.forEach((n, idx) => {
setTimeout(() => this.playTone(n, 0.18, 'square', 0.85), idx * 130);
});
}
startLoop(mode = 'menu', routeRoot = 261.63) {
this.ensureContext();
this.routeFreq = routeRoot;
if (this.currentMode === mode && this.musicTimer) return;
this.stopLoop();
this.currentMode = mode;
this.step = 0;
const tempoMs = mode === 'race' ? 135 : 175;
const ratios = mode === 'race'
? [1, 1.25, 1.5, 1.25, 1.333, 1.5, 1.875, 2.0]
: [1, 1.125, 1.25, 1.5, 1.25, 1.5, 2.0, 1.5];
this.musicTimer = setInterval(() => {
if (!this.ctx || appSettings.muted || appSettings.musicVolume <= 0) return;
const now = this.ctx.currentTime;
const root = mode === 'race' ? this.routeFreq : 220;
const note = root * ratios[this.step % ratios.length];
const bass = (root * 0.5) * (this.step % 2 === 0 ? 1 : 1.5);
const leadOsc = this.ctx.createOscillator();
const leadGain = this.ctx.createGain();
leadOsc.type = 'square';
leadOsc.frequency.setValueAtTime(note, now);
const mVol = this.getMusicGain();
leadGain.gain.setValueAtTime(mVol, now);
leadGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.11);
leadOsc.connect(leadGain);
leadGain.connect(this.ctx.destination);
leadOsc.start(now);
leadOsc.stop(now + 0.12);
if (this.step % 2 === 0) {
const bassOsc = this.ctx.createOscillator();
const bassGain = this.ctx.createGain();
bassOsc.type = 'triangle';
bassOsc.frequency.setValueAtTime(bass, now);
bassGain.gain.setValueAtTime(mVol * 0.9, now);
bassGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.18);
bassOsc.connect(bassGain);
bassGain.connect(this.ctx.destination);
bassOsc.start(now);
bassOsc.stop(now + 0.2);
}
this.step++;
}, tempoMs);
}
updateEnginePitch(speedKmh, active) {
if (!this.ctx || appSettings.muted || !active) {
if (this.engineGain && this.ctx) {
this.engineGain.gain.setValueAtTime(0.0001, this.ctx.currentTime);
}
return;
}
if (!this.engineOsc) {
this.engineOsc = this.ctx.createOscillator();
this.engineGain = this.ctx.createGain();
this.engineOsc.type = 'sawtooth';
this.engineGain.gain.setValueAtTime(0.0001, this.ctx.currentTime);
this.engineOsc.connect(this.engineGain);
this.engineGain.connect(this.ctx.destination);
this.engineOsc.start();
}
const now = this.ctx.currentTime;
const targetFreq = 58 + (speedKmh / 550) * 115;
this.engineOsc.frequency.setTargetAtTime(targetFreq, now, 0.05);
this.engineGain.gain.setTargetAtTime(this.getSfxGain() * 0.16, now, 0.05);
}
stopLoop() {
if (this.musicTimer) {
clearInterval(this.musicTimer);
this.musicTimer = null;
}
this.currentMode = 'none';
}
}
const audio = new ChiptuneAudioEngine();
// ============================================================================
// 4. 3D LOW-POLY AIRCRAFT FACTORY & WORLD BUILDERS (THREE.JS)
// ============================================================================
function makeLowPolyMaterial(colorHex, roughness = 0.48, metalness = 0.12, emissiveHex = 0x000000) {
return new THREE.MeshStandardMaterial({
color: colorHex,
roughness,
metalness,
emissive: emissiveHex,
flatShading: true
});
}
export function createLowPolyAirplaneMesh(planeConfig) {
const group = new THREE.Group();
group.name = `Plane_${planeConfig.id}`;
const matPrimary = makeLowPolyMaterial(planeConfig.primary, 0.4, 0.18);
const matSecondary = makeLowPolyMaterial(planeConfig.secondary, 0.45, 0.12);
const matAccent = makeLowPolyMaterial(planeConfig.accent, 0.35, 0.2);
const matDark = makeLowPolyMaterial(0x1b2234, 0.6, 0.3);
const matCanopy = new THREE.MeshStandardMaterial({
color: 0x7ce8ff,
roughness: 0.15,
metalness: 0.75,
transparent: true,
opacity: 0.84,
flatShading: true
});
// 1. Nose Cowl + Main Fuselage + Rear Tail Cone
const cowlGeo = new THREE.CylinderGeometry(0.42, 0.56, 1.15, 8);
cowlGeo.rotateX(Math.PI / 2);
const cowl = new THREE.Mesh(cowlGeo, matSecondary);
cowl.position.z = -1.65;
cowl.castShadow = true;
group.add(cowl);
const bodyGeo = new THREE.CylinderGeometry(0.56, 0.48, 2.2, 8);
bodyGeo.rotateX(Math.PI / 2);
const body = new THREE.Mesh(bodyGeo, matPrimary);
body.position.z = 0;
body.castShadow = true;
group.add(body);
const tailConeGeo = new THREE.CylinderGeometry(0.48, 0.18, 1.85, 8);
tailConeGeo.rotateX(Math.PI / 2);
const tailCone = new THREE.Mesh(tailConeGeo, matPrimary);
tailCone.position.z = 2.0;
tailCone.castShadow = true;
group.add(tailCone);
// Fuselage Racing Stripe Band
const bandGeo = new THREE.CylinderGeometry(0.575, 0.53, 0.55, 8);
bandGeo.rotateX(Math.PI / 2);
const band = new THREE.Mesh(bandGeo, matAccent);
band.position.z = -0.35;
group.add(band);
// 2. Bubble Canopy & Pilot Helmet
const canopyGeo = new THREE.dodecahedronGeometry ? new THREE.DodecahedronGeometry(0.44, 0) : new THREE.SphereGeometry(0.44, 7, 5);
canopyGeo.scale(0.88, 0.72, 2.1);
const canopy = new THREE.Mesh(canopyGeo, matCanopy);
canopy.position.set(0, 0.42, -0.1);
group.add(canopy);
const helmetGeo = new THREE.OctahedronGeometry(0.19, 1);
const helmet = new THREE.Mesh(helmetGeo, matSecondary);
helmet.position.set(0, 0.48, -0.05);
group.add(helmet);
// 3. Main Wings (Varied by planeConfig.wingStyle)
const wingSpan = planeConfig.wingStyle === 'wide' ? 6.2 : 5.4;
const wingChord = planeConfig.wingStyle === 'delta' ? 1.65 : 1.15;
const wingGeo = new THREE.BoxGeometry(wingSpan, 0.14, wingChord);
const mainWing = new THREE.Mesh(wingGeo, matPrimary);
mainWing.position.set(0, -0.05, -0.2);
mainWing.castShadow = true;
group.add(mainWing);
// Checkered/Striped Wingtip Panels
[-1, 1].forEach((side) => {
const stripe = new THREE.Mesh(
new THREE.BoxGeometry(0.85, 0.155, wingChord * 0.96),
matSecondary
);
stripe.position.set(side * (wingSpan * 0.34), -0.05, -0.2);
group.add(stripe);
const tip = new THREE.Mesh(
new THREE.BoxGeometry(0.42, 0.16, wingChord * 0.92),
matAccent
);
tip.position.set(side * (wingSpan * 0.47), -0.04, -0.2);
group.add(tip);
if (planeConfig.wingStyle === 'pods') {
const podGeo = new THREE.ConeGeometry(0.18, 0.9, 6);
podGeo.rotateX(Math.PI / 2);
const pod = new THREE.Mesh(podGeo, matAccent);
pod.position.set(side * (wingSpan * 0.49), -0.05, -0.2);
group.add(pod);
}
});
// 4. Tail Fin & Horizontal Stabilizers
const hStab = new THREE.Mesh(new THREE.BoxGeometry(2.15, 0.11, 0.68), matSecondary);
hStab.position.set(0, 0.08, 2.55);
group.add(hStab);
if (planeConfig.tailStyle === 'twin') {
[-0.55, 0.55].forEach((xOff) => {
const vFin = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.95, 0.78), matAccent);
vFin.position.set(xOff, 0.5, 2.48);
vFin.rotation.z = xOff > 0 ? -0.14 : 0.14;
group.add(vFin);
});
} else if (planeConfig.tailStyle === 'vtail') {
[-0.35, 0.35].forEach((xOff) => {
const vFin = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.0, 0.75), matAccent);
vFin.position.set(xOff, 0.48, 2.5);
vFin.rotation.z = xOff > 0 ? -0.48 : 0.48;
group.add(vFin);
});
} else {
const vFin = new THREE.Mesh(new THREE.BoxGeometry(0.14, 1.08, 0.85), matPrimary);
vFin.position.set(0, 0.56, 2.48);
const vFinTip = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.32, 0.82), matSecondary);
vFinTip.position.set(0, 0.95, 2.48);
group.add(vFin, vFinTip);
}
// 5. Fixed Landing Gear Spats
[-0.78, 0.78].forEach((xOff) => {
const strut = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.52, 0.14), matDark);
strut.position.set(xOff, -0.38, -0.55);
strut.rotation.z = xOff > 0 ? -0.22 : 0.22;
const spatGeo = new THREE.OctahedronGeometry(0.22, 1);
spatGeo.scale(0.65, 0.7, 1.8);
const spat = new THREE.Mesh(spatGeo, matPrimary);
spat.position.set(xOff * 1.1, -0.64, -0.55);
group.add(strut, spat);
});
// 6. Spinning 3-Blade Propeller + Spinner Cone
const propGroup = new THREE.Group();
propGroup.position.set(0, 0, -2.28);
const spinnerGeo = new THREE.ConeGeometry(0.32, 0.55, 8);
spinnerGeo.rotateX(-Math.PI / 2);
const spinner = new THREE.Mesh(spinnerGeo, matAccent);
propGroup.add(spinner);
for (let b = 0; b < 3; b++) {
const bladeWrap = new THREE.Group();
bladeWrap.rotation.z = (b * Math.PI * 2) / 3;
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.14, 1.35, 0.04), matDark);
blade.position.y = 0.65;
const tip = new THREE.Mesh(new THREE.BoxGeometry(0.145, 0.22, 0.045), matAccent);
tip.position.y = 1.22;
bladeWrap.add(blade, tip);
propGroup.add(bladeWrap);
}
group.add(propGroup);
// 7. Boost Afterburner Flame Cone (Toggled when boosting)
const flameMat = new THREE.MeshBasicMaterial({
color: 0x00ffff,
transparent: true,
opacity: 0.85
});
const flameGeo = new THREE.ConeGeometry(0.28, 1.4, 7);
flameGeo.rotateX(Math.PI / 2);
const flameMesh = new THREE.Mesh(flameGeo, flameMat);
flameMesh.position.set(0, 0, 3.35);
flameMesh.visible = false;
group.add(flameMesh);
group.userData = {
propeller: propGroup,
flame: flameMesh,
wingSpan
};
return group;
}
// Create a 3D Checkered Race Gate Arch matching Reference 1 & 3
function createRaceGateArch3D(gateIndex, isStartLine = false) {
const gateGroup = new THREE.Group();
const pillarHeight = 16;
const archWidth = 24;
const redMat = makeLowPolyMaterial(0xe6222e, 0.45);
const whiteMat = makeLowPolyMaterial(0xf8fbff, 0.45);
const bannerMat = makeLowPolyMaterial(isStartLine ? 0xffcc29 : 0xe6222e, 0.35, 0.1, isStartLine ? 0x332200 : 0x220000);
// Left & Right Checkered Pylons
[-archWidth / 2, archWidth / 2].forEach((xPos) => {
const pylonGroup = new THREE.Group();
pylonGroup.position.set(xPos, 0, 0);
const segCount = 6;
const segH = pillarHeight / segCount;
for (let i = 0; i < segCount; i++) {
const seg = new THREE.Mesh(
new THREE.CylinderGeometry(0.85, 0.95, segH, 7),
i % 2 === 0 ? redMat : whiteMat
);
seg.position.y = (i - segCount / 2 + 0.5) * segH;
seg.castShadow = true;
pylonGroup.add(seg);
}
gateGroup.add(pylonGroup);
});
// Top Overhead Truss Banner with ">>>" Chevrons
const topBeam = new THREE.Mesh(
new THREE.BoxGeometry(archWidth + 2.2, 2.4, 1.4),
bannerMat
);
topBeam.position.y = pillarHeight / 2 + 0.8;
gateGroup.add(topBeam);
for (let c = -1; c <= 1; c++) {
const chev = new THREE.Mesh(new THREE.OctahedronGeometry(0.7, 0), whiteMat);
chev.position.set(c * 2.5, pillarHeight / 2 + 0.8, 0.8);
gateGroup.add(chev);
}
const ringMat = new THREE.MeshBasicMaterial({
color: isStartLine ? 0xffea00 : 0x52e5ff,
wireframe: true,
transparent: true,
opacity: 0.35
});
const ring = new THREE.Mesh(new THREE.RingGeometry(8.5, 9.2, 12), ringMat);
gateGroup.add(ring);
gateGroup.userData = { ring, gateIndex };
return gateGroup;
}
// Create a Low-Poly Cloud Cluster
function createLowPolyCloudCluster(scale = 1) {
const cloud = new THREE.Group();
const cloudMat = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 0.85,
metalness: 0.0,
flatShading: true
});
const puffs = [
[0, 0, 0, 4.2],
[-3.2, -0.6, 0.8, 3.1],
[3.4, -0.4, -0.5, 3.3],
[1.2, 1.6, 0.4, 2.8],
[-1.5, 1.3, -0.6, 2.6]
];
puffs.forEach(([x, y, z, r]) => {
const geo = new THREE.DodecahedronGeometry(r, 1);
const mesh = new THREE.Mesh(geo, cloudMat);
mesh.position.set(x, y, z);
cloud.add(mesh);
});
cloud.scale.setScalar(scale);
return cloud;
}
// Build Route-Specific 3D Low-Poly World Landmarks
function buildRouteLandmark3D(routeId) {
const landmark = new THREE.Group();
const stoneMat = makeLowPolyMaterial(0xe0cda9, 0.7);
const redMat = makeLowPolyMaterial(0xe53935, 0.45);
const whiteMat = makeLowPolyMaterial(0xf5f7fa, 0.45);
const goldMat = makeLowPolyMaterial(0xffca28, 0.3, 0.4);
const glassMat = makeLowPolyMaterial(0x29b6f6, 0.25, 0.6);
const greenMat = makeLowPolyMaterial(0x2e7d32, 0.75);
if (routeId === 'miami') {
for (let i = 0; i < 9; i++) {
const h = 18 + (i % 4) * 10;
const bldg = new THREE.Mesh(
new THREE.BoxGeometry(10, h, 10),
makeLowPolyMaterial(i % 2 === 0 ? 0xff80ab : 0x80deea, 0.4)
);
bldg.position.set((i - 4) * 16, h / 2, -25);
landmark.add(bldg);
}
} else if (routeId === 'tokyo') {
const base = new THREE.Mesh(new THREE.CylinderGeometry(3, 14, 42, 4), redMat);
base.position.y = 21;
const mid = new THREE.Mesh(new THREE.CylinderGeometry(1.5, 3, 26, 4), whiteMat);
mid.position.y = 55;
const fuji = new THREE.Mesh(new THREE.ConeGeometry(65, 58, 9), makeLowPolyMaterial(0x3949ab, 0.7));
fuji.position.set(-110, 29, -120);
const snowCap = new THREE.Mesh(new THREE.ConeGeometry(24, 22, 9), whiteMat);
snowCap.position.set(-110, 48, -120);
landmark.add(base, mid, fuji, snowCap);
} else if (routeId === 'rome') {
const colOuter = new THREE.Mesh(new THREE.CylinderGeometry(28, 28, 22, 14, 1, true), stoneMat);
colOuter.material.side = THREE.DoubleSide;
colOuter.position.y = 11;
const dome = new THREE.Mesh(new THREE.SphereGeometry(16, 10, 8), stoneMat);
dome.position.set(55, 18, -20);
landmark.add(colOuter, dome);
} else if (routeId === 'shanghai') {
const column = new THREE.Mesh(new THREE.CylinderGeometry(2.6, 4.2, 68, 8), whiteMat);
column.position.y = 34;
const lowerSphere = new THREE.Mesh(new THREE.DodecahedronGeometry(10, 1), makeLowPolyMaterial(0xf50057, 0.3));
lowerSphere.position.y = 24;
const upperSphere = new THREE.Mesh(new THREE.DodecahedronGeometry(6.5, 1), makeLowPolyMaterial(0xf50057, 0.3));
upperSphere.position.y = 52;
landmark.add(column, lowerSphere, upperSphere);
} else if (routeId === 'egypt') {
const p1 = new THREE.Mesh(new THREE.ConeGeometry(38, 46, 4), goldMat);
p1.position.set(0, 23, 0);
const p2 = new THREE.Mesh(new THREE.ConeGeometry(28, 34, 4), stoneMat);
p2.position.set(58, 17, -38);
landmark.add(p1, p2);
} else if (routeId === 'paris') {
const eiffelMat = makeLowPolyMaterial(0x8d6e63, 0.5, 0.3);
const tier1 = new THREE.Mesh(new THREE.CylinderGeometry(7, 16, 22, 4), eiffelMat);
tier1.position.y = 11;
const tier2 = new THREE.Mesh(new THREE.CylinderGeometry(3.2, 7, 24, 4), eiffelMat);
tier2.position.y = 34;
const spire = new THREE.Mesh(new THREE.CylinderGeometry(0.6, 3.2, 28, 4), eiffelMat);
spire.position.y = 60;
landmark.add(tier1, tier2, spire);
} else {
const peak = new THREE.Mesh(new THREE.ConeGeometry(34, 52, 8), greenMat);
peak.position.y = 26;
const torso = new THREE.Mesh(new THREE.BoxGeometry(4.5, 16, 3.5), whiteMat);
torso.position.y = 58;
const arms = new THREE.Mesh(new THREE.BoxGeometry(16, 2.8, 2.8), whiteMat);
arms.position.y = 62;
landmark.add(peak, torso, arms);
}
return landmark;
}
// ============================================================================
// 5. CORE 3D ENGINE & SCENE CONTROLLER
// ============================================================================
const threeContainer = document.getElementById('three-container');
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
threeContainer.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1b75ff);
scene.fog = new THREE.FogExp2(0x8cd3ff, 0.0018);
const camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 0.5, 1600);
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x29437a, 1.15);
scene.add(hemiLight);
const sunLight = new THREE.DirectionalLight(0xfff5d6, 2.1);
sunLight.position.set(120, 220, 140);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(1024, 1024);
sunLight.shadow.camera.near = 10;
sunLight.shadow.camera.far = 650;
const d = 140;
sunLight.shadow.camera.left = -d;
sunLight.shadow.camera.right = d;
sunLight.shadow.camera.top = d;
sunLight.shadow.camera.bottom = -d;
scene.add(sunLight);
const worldRoot = new THREE.Group();
scene.add(worldRoot);
function clearWorldRoot() {
while (worldRoot.children.length > 0) {
const child = worldRoot.children[0];
worldRoot.remove(child);
child.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
});
}
}
// ============================================================================
// 6. SCENE BUILDERS: MENU DIORAMA, 3D HANGAR TURNTABLE, & 3D RACE WORLD
// ============================================================================
let currentAppState = 'menu';
let planesReturnTarget = 'menu';
let cameraModeIndex = 0; // 0: Chase, 1: Far Chase, 2: Nose/Cockpit
let menuAnimData = null;
let hangarAnimData = null;
let raceState = null;
// --- 6A. BUILD 3D MAIN MENU DIORAMA ---
function buildMenu3DDiorama() {
clearWorldRoot();
scene.background.setHex(0x1e78ff);
scene.fog.color.setHex(0x9be2ff);
scene.fog.density = 0.0022;
const water = new THREE.Mesh(
new THREE.PlaneGeometry(1400, 1400),
makeLowPolyMaterial(0x0088cc, 0.35, 0.2)
);
water.rotation.x = -Math.PI / 2;
water.position.y = -18;
worldRoot.add(water);
const mtnGroup = new THREE.Group();
const cliff = new THREE.Mesh(new THREE.ConeGeometry(42, 68, 7), makeLowPolyMaterial(0x2e7d32, 0.8));
cliff.position.set(-48, 12, -55);
const snowPeak = new THREE.Mesh(new THREE.ConeGeometry(36, 75, 7), makeLowPolyMaterial(0xecfffe, 0.5));
snowPeak.position.set(-72, 26, -95);
const castle = new THREE.Mesh(new THREE.BoxGeometry(14, 10, 12), makeLowPolyMaterial(0xffe0b2, 0.6));
castle.position.set(-46, 38, -48);
const spire = new THREE.Mesh(new THREE.ConeGeometry(4, 10, 6), makeLowPolyMaterial(0x1565c0, 0.5));
spire.position.set(-46, 48, -48);
const waterfall = new THREE.Mesh(new THREE.BoxGeometry(5, 42, 2), makeLowPolyMaterial(0x80d8ff, 0.2));
waterfall.position.set(-38, 12, -38);
mtnGroup.add(cliff, snowPeak, castle, spire, waterfall);
worldRoot.add(mtnGroup);
const island = new THREE.Mesh(new THREE.CylinderGeometry(22, 32, 12, 8), makeLowPolyMaterial(0x388e3c, 0.8));
island.position.set(36, -13, -72);
worldRoot.add(island);
const gate = createRaceGateArch3D(0, true);
gate.position.set(16, -2, -62);
gate.scale.setScalar(0.85);
worldRoot.add(gate);
const clouds = [];
for (let i = 0; i < 14; i++) {
const c = createLowPolyCloudCluster(1.2 + (i % 3) * 0.5);
c.position.set((Math.random() - 0.5) * 180, -4 + Math.random() * 35, -30 - Math.random() * 140);
worldRoot.add(c);
clouds.push(c);
}
const heroPlane = createLowPolyAirplaneMesh(PLANES_DATA[appSettings.selectedPlaneIndex]);
heroPlane.position.set(-7.5, -1.8, 11.5);
heroPlane.rotation.set(0.18, -0.55, -0.42);
worldRoot.add(heroPlane);
const rivalBlue = createLowPolyAirplaneMesh(PLANES_DATA[1]);
rivalBlue.position.set(11, 5.2, -8);
rivalBlue.rotation.set(-0.08, 0.65, 0.32);
worldRoot.add(rivalBlue);
const rivalYellow = createLowPolyAirplaneMesh(PLANES_DATA[3]);
rivalYellow.position.set(14, 1.2, -18);
rivalYellow.rotation.set(0.05, 0.72, 0.25);
worldRoot.add(rivalYellow);
camera.position.set(0, 2.5, 22);
camera.lookAt(0, 1.5, -20);
menuAnimData = { heroPlane, rivalBlue, rivalYellow, clouds, t: 0 };
}
// --- 6B. BUILD 3D HANGAR TURNTABLE SCENE ---
function buildHangar3DScene() {
clearWorldRoot();
scene.background.setHex(0x1652b8);
scene.fog.color.setHex(0x64b5f6);
scene.fog.density = 0.004;
const tarmac = new THREE.Mesh(
new THREE.CylinderGeometry(85, 85, 1, 24),
makeLowPolyMaterial(0x283044, 0.8, 0.1)
);
tarmac.position.y = -2.1;
tarmac.receiveShadow = true;
worldRoot.add(tarmac);
const padBase = new THREE.Mesh(
new THREE.CylinderGeometry(5.2, 5.8, 0.45, 24),
makeLowPolyMaterial(0x1b2848, 0.4, 0.5)
);
padBase.position.set(-1.6, -1.65, 0);
padBase.receiveShadow = true;
worldRoot.add(padBase);
const neonRing = new THREE.Mesh(
new THREE.RingGeometry(4.5, 4.85, 32),
new THREE.MeshBasicMaterial({ color: 0x00e5ff, side: THREE.DoubleSide })
);
neonRing.rotation.x = -Math.PI / 2;
neonRing.position.set(-1.6, -1.41, 0);
worldRoot.add(neonRing);
const hangarWall = new THREE.Mesh(
new THREE.BoxGeometry(22, 12, 14),
makeLowPolyMaterial(0x37474f, 0.7)
);
hangarWall.position.set(-18, 4, -12);
const hangarRoof = new THREE.Mesh(
new THREE.BoxGeometry(24, 1.2, 16),
makeLowPolyMaterial(0xd84315, 0.6)
);
hangarRoof.position.set(-18, 10.2, -12);
worldRoot.add(hangarWall, hangarRoof);
const distMtn = new THREE.Mesh(
new THREE.ConeGeometry(28, 38, 8),
makeLowPolyMaterial(0x3949ab, 0.75)
);
distMtn.position.set(-8, 15, -55);
worldRoot.add(distMtn);
const turntablePivot = new THREE.Group();
turntablePivot.position.set(-1.6, -0.55, 0);
worldRoot.add(turntablePivot);
camera.position.set(-1.2, 1.55, 9.8);
camera.lookAt(-1.2, -0.2, 0);
hangarAnimData = {
pivot: turntablePivot,
planeMesh: null,
rotY: 0.65,
isDragging: false,
lastX: 0
};
updateHangarSelectedPlane(appSettings.selectedPlaneIndex);
}
function updateHangarSelectedPlane(index) {
appSettings.selectedPlaneIndex = index;
saveSettings(appSettings);
const planeData = PLANES_DATA[index];
document.getElementById('hangar-plane-name').textContent = planeData.name;
document.getElementById('hangar-plane-class').textContent = `Racer Class • #${planeData.num}`;
document.getElementById('hangar-plane-desc').textContent = planeData.desc;
const renderBar = (elId, val) => {
const container = document.getElementById(elId);
container.innerHTML = '';
for (let i = 1; i <= 10; i++) {
const seg = document.createElement('div');
seg.className = 'stat-seg' + (i <= val ? ' filled' : '');
container.appendChild(seg);
}
};
renderBar('stat-speed', planeData.stats.speed);
renderBar('stat-accel', planeData.stats.accel);
renderBar('stat-handling', planeData.stats.handling);
renderBar('stat-boost', planeData.stats.boost);
document.querySelectorAll('.plane-card').forEach((card, idx) => {
card.classList.toggle('selected', idx === index);
});
if (hangarAnimData && hangarAnimData.pivot) {
while (hangarAnimData.pivot.children.length > 0) {
hangarAnimData.pivot.remove(hangarAnimData.pivot.children[0]);
}
const mesh = createLowPolyAirplaneMesh(planeData);
mesh.scale.setScalar(1.08);
hangarAnimData.pivot.add(mesh);
hangarAnimData.planeMesh = mesh;
}
}
// --- 6C. BUILD 3D SPLINE RACE WORLD FOR ANY OF THE 7 ROUTES ---
function build3DRaceWorld(routeConfig, isMultiplayer = false, customRoster = null) {
clearWorldRoot();
const route = routeConfig || ROUTES_DATA[appSettings.selectedRouteIndex];
scene.background.setHex(route.skyTop);
scene.fog.color.setHex(route.skyHorizon);
scene.fog.density = 0.0014;
const pts = route.waypoints.map(([x, y, z]) => new THREE.Vector3(x, y, z));
const curve = new THREE.CatmullRomCurve3(pts, true, 'catmullrom', 0.5);
const trackLength = curve.getLength();
const waterPlane = new THREE.Mesh(
new THREE.PlaneGeometry(3200, 3200),
makeLowPolyMaterial(route.waterColor, 0.35, 0.15)
);
waterPlane.rotation.x = -Math.PI / 2;
waterPlane.position.y = -4;
worldRoot.add(waterPlane);
const gateCount = 18;
const gates = [];
for (let i = 0; i < gateCount; i++) {
const u = i / gateCount;
const pos = curve.getPointAt(u);
const tangent = curve.getTangentAt(u).normalize();
const gateMesh = createRaceGateArch3D(i, i === 0);
gateMesh.position.copy(pos);
const lookTarget = pos.clone().add(tangent);
gateMesh.lookAt(lookTarget);
worldRoot.add(gateMesh);
gates.push({ u, pos, tangent, mesh: gateMesh, passedByPlayer: false });
}
for (let k = 0; k < 28; k++) {
const u = k / 28;
const pos = curve.getPointAt(u);
const tangent = curve.getTangentAt(u).normalize();
const right = new THREE.Vector3().crossVectors(tangent, new THREE.Vector3(0, 1, 0)).normalize();
const side = k % 2 === 0 ? 1 : -1;
const offsetDist = 48 + (k % 3) * 26;
const islandPos = pos.clone().addScaledVector(right, side * offsetDist);
islandPos.y = -4;
const hillH = 22 + (k % 5) * 14;
const hill = new THREE.Mesh(
new THREE.ConeGeometry(26 + (k % 4) * 8, hillH, 7),
makeLowPolyMaterial(route.groundColor, 0.8)
);
hill.position.copy(islandPos);
hill.position.y = hillH / 2 - 5;
worldRoot.add(hill);
if (k % 4 === 0) {
const lm = buildRouteLandmark3D(route.id);
lm.position.copy(islandPos);
lm.position.y = 0;
lm.scale.setScalar(0.65);
worldRoot.add(lm);
}
}
const clouds = [];
for (let c = 0; c < 36; c++) {
const u = (c / 36) % 1;
const p = curve.getPointAt(u);
const cloud = createLowPolyCloudCluster(1.5 + (c % 3) * 0.7);
cloud.position.set(
p.x + (Math.random() - 0.5) * 240,
p.y + 18 + (Math.random() - 0.5) * 32,
p.z + (Math.random() - 0.5) * 240
);
worldRoot.add(cloud);
clouds.push(cloud);
}
const racers = [];
const playerPlaneData = PLANES_DATA[appSettings.selectedPlaneIndex];
const makeRacer = (id, name, planeData, isLocal, isAI, startSlot) => {
const mesh = createLowPolyAirplaneMesh(planeData);
worldRoot.add(mesh);
const trailMax = 34;
const leftTrailPositions = new Float32Array(trailMax * 3);
const rightTrailPositions = new Float32Array(trailMax * 3);
const trailMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.65 });
const leftLine = new THREE.Line(new THREE.BufferGeometry(), trailMat);
const rightLine = new THREE.Line(new THREE.BufferGeometry(), trailMat);
leftLine.frustumCulled = false;
rightLine.frustumCulled = false;
worldRoot.add(leftLine, rightLine);
const lateralSlots = [0, -7, 7, -13, 13, -4];
const startS = (trackLength - startSlot * 14) % trackLength;
return {
id,
name,
planeData,
mesh,
isLocal,
isAI,
s: startS,
lap: 1,
totalDistance: -startSlot * 14,
xOffset: lateralSlots[startSlot % lateralSlots.length],
yOffset: (startSlot % 2) * 1.5,
vx: 0,
vy: 0,
speedKmh: 280,
boostEnergy: 100,
boosting: false,
bankAngle: 0,
pitchAngle: 0,
nextGateIndex: 1,
finished: false,
finishTime: 0,
aiSkill: 0.94 + (startSlot * 0.025) * appSettings.aiDifficulty,
aiTargetX: (startSlot - 2.5) * 3.8,
leftTrail: [],
rightTrail: [],
leftLine,
rightLine,
leftTrailPositions,
rightTrailPositions
};
};
racers.push(makeRacer('local', appSettings.pilotName, playerPlaneData, true, false, 0));
if (isMultiplayer && Array.isArray(customRoster) && customRoster.length > 0) {
let slotIdx = 1;
customRoster.forEach((peer) => {
if (peer.id === mpState.clientId) return;
const pData = PLANES_DATA.find((p) => p.id === peer.planeId) || PLANES_DATA[slotIdx % PLANES_DATA.length];
racers.push(makeRacer(peer.id, peer.name, pData, false, !!peer.isAI, slotIdx++));
});
} else {
for (let r = 1; r <= 5; r++) {
const aiPlaneIdx = (appSettings.selectedPlaneIndex + r * 2) % PLANES_DATA.length;
const aiPlane = PLANES_DATA[aiPlaneIdx];
racers.push(makeRacer(`ai-${r}`, aiPlane.name, aiPlane, false, true, r));
}
}
raceState = {
route,
curve,
trackLength,
gates,
clouds,
racers,
localRacer: racers[0],
totalLaps: 3,
status: 'countdown',
countdownTimer: 3.4,
lastBeepSecond: 4,
elapsedTime: 0,
isMultiplayer
};
positionRacersIn3DSpace(0.016);
updateChaseCamera(1.0);
}
// Position all racers along the 3D closed spline
function positionRacersIn3DSpace(dt) {
if (!raceState) return;
const { curve, trackLength, racers } = raceState;
const upVec = new THREE.Vector3(0, 1, 0);
racers.forEach((r) => {
const u = ((r.s % trackLength) + trackLength) % trackLength / trackLength;
const centerPt = curve.getPointAt(u);
const tangent = curve.getTangentAt(u).normalize();
const right = new THREE.Vector3().crossVectors(tangent, upVec).normalize();
const normalUp = new THREE.Vector3().crossVectors(right, tangent).normalize();
const worldPos = centerPt.clone()
.addScaledVector(right, r.xOffset)
.addScaledVector(normalUp, r.yOffset);
r.mesh.position.copy(worldPos);
const lookTarget = worldPos.clone()
.addScaledVector(tangent, 12)
.addScaledVector(right, r.vx * 0.28)
.addScaledVector(normalUp, r.vy * 0.35);
r.mesh.lookAt(lookTarget);
r.mesh.rotateZ(r.bankAngle);
if (r.mesh.userData.propeller) {
r.mesh.userData.propeller.rotation.z += dt * (28 + r.speedKmh * 0.08);
}
if (r.mesh.userData.flame) {
r.mesh.userData.flame.visible = r.boosting;
if (r.boosting) {
r.mesh.userData.flame.scale.setScalar(0.85 + Math.random() * 0.35);
}
}
const halfSpan = (r.mesh.userData.wingSpan || 5.2) * 0.48;
const leftTip = new THREE.Vector3(-halfSpan, -0.05, 0.3).applyMatrix4(r.mesh.matrixWorld);
const rightTip = new THREE.Vector3(halfSpan, -0.05, 0.3).applyMatrix4(r.mesh.matrixWorld);
r.leftTrail.unshift(leftTip);
r.rightTrail.unshift(rightTip);
if (r.leftTrail.length > 26) r.leftTrail.pop();
if (r.rightTrail.length > 26) r.rightTrail.pop();
const updateRibbon = (ptsArr, lineObj) => {
const flat = new Float32Array(ptsArr.length * 3);
for (let i = 0; i < ptsArr.length; i++) {
flat[i * 3] = ptsArr[i].x;
flat[i * 3 + 1] = ptsArr[i].y;
flat[i * 3 + 2] = ptsArr[i].z;
}
lineObj.geometry.setAttribute('position', new THREE.BufferAttribute(flat, 3));
};
if (r.leftTrail.length > 1) {
updateRibbon(r.leftTrail, r.leftLine);
updateRibbon(r.rightTrail, r.rightLine);
}
});
}
function updateChaseCamera(lerpFactor = 0.14) {
if (!raceState || !raceState.localRacer) return;
const player = raceState.localRacer;
const { curve, trackLength } = raceState;
const u = ((player.s % trackLength) + trackLength) % trackLength / trackLength;
const tangent = curve.getTangentAt(u).normalize();
const upVec = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3().crossVectors(tangent, upVec).normalize();
const planePos = player.mesh.position;
let camDist = 9.5;
let camHeight = 2.85;
let lookAhead = 18;
if (cameraModeIndex === 1) {
camDist = 14.2;
camHeight = 4.4;
} else if (cameraModeIndex === 2) {
camDist = -1.6;
camHeight = 0.65;
lookAhead = 35;
}
const desiredCamPos = planePos.clone()
.addScaledVector(tangent, -camDist)
.addScaledVector(upVec, camHeight)
.addScaledVector(right, player.xOffset * -0.05);
camera.position.lerp(desiredCamPos, lerpFactor);
const lookTarget = planePos.clone()
.addScaledVector(tangent, lookAhead)
.addScaledVector(upVec, 0.7);
camera.lookAt(lookTarget);
}
// ============================================================================
// 7. INPUT HANDLING (KEYBOARD, MOUSE TURNTABLE & MULTI-TOUCH)
// ============================================================================
const inputState = {
left: false,
right: false,
up: false,
down: false,
boost: false
};
window.addEventListener('keydown', (e) => {
if (['ArrowLeft', 'KeyA'].includes(e.code)) inputState.left = true;
if (['ArrowRight', 'KeyD'].includes(e.code)) inputState.right = true;
if (['ArrowUp', 'KeyW'].includes(e.code)) inputState.up = true;
if (['ArrowDown', 'KeyS'].includes(e.code)) inputState.down = true;
if (['Space', 'ShiftLeft', 'ShiftRight'].includes(e.code)) {
inputState.boost = true;
if (currentAppState === 'racing') e.preventDefault();
}
if (['Escape', 'KeyP'].includes(e.code)) {
if (currentAppState === 'racing') showScreen('pause');
else if (currentAppState === 'pause') showScreen('racing');
}
if (e.code === 'KeyC' && currentAppState === 'racing') {
cycleCameraMode();
}
});
window.addEventListener('keyup', (e) => {
if (['ArrowLeft', 'KeyA'].includes(e.code)) inputState.left = false;
if (['ArrowRight', 'KeyD'].includes(e.code)) inputState.right = false;
if (['ArrowUp', 'KeyW'].includes(e.code)) inputState.up = false;
if (['ArrowDown', 'KeyS'].includes(e.code)) inputState.down = false;
if (['Space', 'ShiftLeft', 'ShiftRight'].includes(e.code)) inputState.boost = false;
});
function bindTouchButton(elId, keyProp) {
const btn = document.getElementById(elId);
if (!btn) return;
const setFlag = (val, ev) => {
if (ev) ev.preventDefault();
inputState[keyProp] = val;
};
btn.addEventListener('pointerdown', (e) => setFlag(true, e));
btn.addEventListener('pointerup', (e) => setFlag(false, e));
btn.addEventListener('pointerleave', (e) => setFlag(false, e));
btn.addEventListener('pointercancel', (e) => setFlag(false, e));
}
bindTouchButton('touch-left', 'left');
bindTouchButton('touch-right', 'right');
bindTouchButton('touch-up', 'up');
bindTouchButton('touch-down', 'down');
bindTouchButton('touch-boost', 'boost');
window.addEventListener('pointerdown', (e) => {
if (currentAppState === 'planes' && hangarAnimData) {
hangarAnimData.isDragging = true;
hangarAnimData.lastX = e.clientX;
}
});
window.addEventListener('pointermove', (e) => {
if (currentAppState === 'planes' && hangarAnimData && hangarAnimData.isDragging) {
const dx = e.clientX - hangarAnimData.lastX;
hangarAnimData.lastX = e.clientX;
hangarAnimData.rotY += dx * 0.012;
}
});
window.addEventListener('pointerup', () => {
if (hangarAnimData) hangarAnimData.isDragging = false;
});
// ============================================================================
// 8. RACE SIMULATION LOOP, CHECKPOINT DETECTION & HUD RENDERING
// ============================================================================
const minimapCanvas = document.getElementById('minimap-canvas');
const minimapCtx = minimapCanvas.getContext('2d');
const speedBarsEl = document.getElementById('hud-speed-bars');
for (let i = 0; i < 14; i++) {
const seg = document.createElement('div');
seg.className = 'speed-bar-seg';
speedBarsEl.appendChild(seg);
}
function showCheckpointToast(msg) {
const el = document.getElementById('checkpoint-toast');
el.textContent = msg;
el.style.opacity = '1';
clearTimeout(el._timer);
el._timer = setTimeout(() => { el.style.opacity = '0'; }, 1100);
}
function formatTime(sec) {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
const ms = Math.floor((sec % 1) * 100);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(2, '0')}`;
}
function ordinalSuffix(rank) {
if (rank === 1) return '1st';
if (rank === 2) return '2nd';
if (rank === 3) return '3rd';
return `${rank}th`;
}
function updateRaceSimulation(dt) {
if (!raceState) return;
const { localRacer, racers, gates, trackLength, totalLaps, route } = raceState;
if (raceState.status === 'countdown') {
raceState.countdownTimer -= dt;
const secLeft = Math.ceil(raceState.countdownTimer);
const banner = document.getElementById('countdown-banner');
if (secLeft > 0) {
banner.textContent = secLeft === 4 ? 'READY' : String(secLeft);
if (secLeft < raceState.lastBeepSecond && secLeft <= 3) {
raceState.lastBeepSecond = secLeft;
audio.playCountdownBeep(false);
}
} else {
banner.textContent = 'GO!!';
audio.playCountdownBeep(true);
raceState.status = 'active';
setTimeout(() => {
if (document.getElementById('countdown-banner').textContent === 'GO!!') {
document.getElementById('countdown-banner').textContent = '';
}
}, 700);
}
positionRacersIn3DSpace(dt);
updateChaseCamera(0.15);
return;
}
if (raceState.status !== 'active') return;
raceState.elapsedTime += dt;
// --- 8A. UPDATE LOCAL PLAYER 3D FLIGHT PHYSICS ---
const stats = localRacer.planeData.stats;
const topSpeed = 415 + stats.speed * 13;
const accelRate = 115 + stats.accel * 15;
const handleRate = (18 + stats.handling * 2.1) * appSettings.sensitivity;
const boostBonus = 95 + stats.boost * 9;
const steerInput = (inputState.right ? 1 : 0) - (inputState.left ? 1 : 0);
const pitchInput = (inputState.up ? 1 : 0) - (inputState.down ? 1 : 0);
if (inputState.boost && localRacer.boostEnergy > 4) {
if (!localRacer.boosting) audio.playBoostSound();
localRacer.boosting = true;
localRacer.boostEnergy = Math.max(0, localRacer.boostEnergy - dt * 28);
} else {
localRacer.boosting = false;
localRacer.boostEnergy = Math.min(100, localRacer.boostEnergy + dt * (11 + stats.boost * 1.1));
}
const windDrift = Math.sin(raceState.elapsedTime * 0.9) * route.windForce;
localRacer.vx = THREE.MathUtils.lerp(localRacer.vx, steerInput * handleRate + windDrift, dt * 6.5);
localRacer.vy = THREE.MathUtils.lerp(localRacer.vy, pitchInput * (handleRate * 0.55), dt * 6.0);
localRacer.xOffset += localRacer.vx * dt;
localRacer.yOffset = THREE.MathUtils.clamp(localRacer.yOffset + localRacer.vy * dt, -5.5, 8.5);
const offCoursePenalty = Math.abs(localRacer.xOffset) > 13.5 ? 0.82 : 1.0;
if (Math.abs(localRacer.xOffset) > 18) {
localRacer.xOffset = THREE.MathUtils.lerp(localRacer.xOffset, Math.sign(localRacer.xOffset) * 11, dt * 4);
}
const targetSpeed = (topSpeed + (localRacer.boosting ? boostBonus : 0)) * offCoursePenalty;
localRacer.speedKmh = THREE.MathUtils.lerp(localRacer.speedKmh, targetSpeed, dt * (accelRate / 95));
localRacer.bankAngle = THREE.MathUtils.lerp(localRacer.bankAngle, -steerInput * 0.68, dt * 8.5);
const forwardStep = (localRacer.speedKmh * 0.26) * dt;
const prevS = localRacer.s;
localRacer.s = (localRacer.s + forwardStep) % trackLength;
localRacer.totalDistance += forwardStep;
// Check Gate Progression
const activeGate = gates[localRacer.nextGateIndex];
if (activeGate) {
const gateS = activeGate.u * trackLength;
const distToGate = Math.abs(localRacer.s - gateS);
if (distToGate < 14) {
if (Math.abs(localRacer.xOffset) < 13.5) {
localRacer.boostEnergy = Math.min(100, localRacer.boostEnergy + 12);
audio.playCheckpoint();
showCheckpointToast(`GATE ${localRacer.nextGateIndex + 1}/${gates.length} CLEAR! +BOOST`);
}
localRacer.nextGateIndex = (localRacer.nextGateIndex + 1) % gates.length;
}
}
// Check Lap Completion
if (prevS > trackLength * 0.82 && localRacer.s < trackLength * 0.18) {
if (localRacer.lap < totalLaps) {
localRacer.lap++;
audio.playCheckpoint();
showCheckpointToast(`🏁 LAP ${localRacer.lap}/${totalLaps}!`);
} else if (!localRacer.finished) {
localRacer.finished = true;
localRacer.finishTime = raceState.elapsedTime;
audio.playVictoryFanfare();
completeRaceAndShowStandings();
return;
}
}
// --- 8B. UPDATE AI & REMOTE MULTIPLAYER OPPONENTS ---
racers.forEach((r) => {
if (r.isLocal || r.finished) return;
if (r.isAI) {
const aiStats = r.planeData.stats;
const aiBaseSpeed = (402 + aiStats.speed * 11.5) * r.aiSkill;
const wave = Math.sin(raceState.elapsedTime * 1.4 + r.s * 0.02) * 14;
r.speedKmh = THREE.MathUtils.lerp(r.speedKmh, aiBaseSpeed + wave, dt * 2.5);
r.boosting = Math.sin(raceState.elapsedTime * 0.8 + r.aiSkill * 10) > 0.78;
const desiredX = Math.sin(r.s * 0.015 + r.aiSkill * 6) * 7.5;
const dx = desiredX - r.xOffset;
r.vx = THREE.MathUtils.clamp(dx * 2.2, -14, 14);
r.xOffset += r.vx * dt;
r.yOffset = Math.cos(r.s * 0.02 + r.aiSkill * 4) * 2.4;
r.bankAngle = THREE.MathUtils.lerp(r.bankAngle, -THREE.MathUtils.clamp(dx * 0.08, -0.55, 0.55), dt * 6);
const step = (r.speedKmh * 0.26) * dt;
const oldS = r.s;
r.s = (r.s + step) % trackLength;
r.totalDistance += step;
if (oldS > trackLength * 0.82 && r.s < trackLength * 0.18) {
if (r.lap < totalLaps) r.lap++;
else {
r.finished = true;
r.finishTime = raceState.elapsedTime;
}
}
}
});
// Soft Plane-to-Plane Wing Bump Separation
for (let i = 0; i < racers.length; i++) {
for (let j = i + 1; j < racers.length; j++) {
const a = racers[i];
const b = racers[j];
if (Math.abs(a.s - b.s) < 6.5 && Math.abs(a.xOffset - b.xOffset) < 3.4) {
const push = a.xOffset >= b.xOffset ? 1 : -1;
a.xOffset += push * 8 * dt;
b.xOffset -= push * 8 * dt;
}
}
}
if (raceState.isMultiplayer) {
broadcastMultiplayerTelemetry(localRacer);
}
positionRacersIn3DSpace(dt);
updateChaseCamera(0.16);
audio.updateEnginePitch(localRacer.speedKmh, true);
updateRaceHUD();
}
function updateRaceHUD() {
if (!raceState) return;
const { localRacer, racers, totalLaps } = raceState;
const sorted = [...racers].sort((a, b) => b.totalDistance - a.totalDistance);
const playerRank = sorted.findIndex((r) => r.id === localRacer.id) + 1;
document.getElementById('hud-lap-val').textContent = `${Math.min(localRacer.lap, totalLaps)}/${totalLaps}`;
document.getElementById('hud-time-val').textContent = formatTime(raceState.elapsedTime);
document.getElementById('hud-pos-val').innerHTML = `${ordinalSuffix(playerRank)} <small>/${racers.length}</small>`;
document.getElementById('hud-speed-num').textContent = String(Math.round(localRacer.speedKmh));
document.getElementById('hud-boost-fill').style.width = `${Math.round(localRacer.boostEnergy)}%`;
const activeSegs = Math.round((localRacer.speedKmh / 620) * 14);
const segNodes = speedBarsEl.children;
for (let i = 0; i < segNodes.length; i++) {
if (i < activeSegs) {
segNodes[i].style.background = i > 10 ? '#ff3b2f' : (i > 6 ? '#ffcc29' : '#35e86b');
} else {
segNodes[i].style.background = '#1b2d59';
}
}
drawCourseMinimap();
}
function drawCourseMinimap() {
if (!raceState) return;
const { curve, trackLength, racers, localRacer } = raceState;
const w = minimapCanvas.width;
const h = minimapCanvas.height;
minimapCtx.clearRect(0, 0, w, h);
const sampleCount = 60;
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
const pts = [];
for (let i = 0; i <= sampleCount; i++) {
const p = curve.getPointAt(i / sampleCount);
pts.push(p);
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.z < minZ) minZ = p.z;
if (p.z > maxZ) maxZ = p.z;
}
const pad = 16;
const mapX = (x) => pad + ((x - minX) / Math.max(1, maxX - minX)) * (w - pad * 2);
const mapY = (z) => pad + ((z - minZ) / Math.max(1, maxZ - minZ)) * (h - pad * 2);
minimapCtx.strokeStyle = '#6ec6ff';
minimapCtx.lineWidth = 4;
minimapCtx.beginPath();
pts.forEach((p, idx) => {
const mx = mapX(p.x);
const my = mapY(p.z);
if (idx === 0) minimapCtx.moveTo(mx, my);
else minimapCtx.lineTo(mx, my);
});
minimapCtx.closePath();
minimapCtx.stroke();
const startPt = pts[0];
minimapCtx.fillStyle = '#ffffff';
minimapCtx.fillRect(mapX(startPt.x) - 5, mapY(startPt.z) - 5, 10, 10);
racers.forEach((r) => {
const u = ((r.s % trackLength) + trackLength) % trackLength / trackLength;
const p = curve.getPointAt(u);
const mx = mapX(p.x);
const my = mapY(p.z);
minimapCtx.beginPath();
minimapCtx.arc(mx, my, r.isLocal ? 5 : 3.5, 0, Math.PI * 2);
minimapCtx.fillStyle = r.isLocal ? '#ff2938' : '#ffcc29';
minimapCtx.fill();
minimapCtx.strokeStyle = '#ffffff';
minimapCtx.lineWidth = 1.5;
minimapCtx.stroke();
});
}
function completeRaceAndShowStandings() {
if (!raceState) return;
raceState.status = 'finished';
audio.updateEnginePitch(0, false);
const sorted = [...raceState.racers].sort((a, b) => b.totalDistance - a.totalDistance);
const playerRank = sorted.findIndex((r) => r.id === 'local') + 1;
document.getElementById('results-headline').textContent =
playerRank === 1 ? '🏆 1ST PLACE VICTORY!' : `🏁 FINISHED ${ordinalSuffix(playerRank)} PLACE!`;
document.getElementById('results-sub').textContent =
`${raceState.route.name} • Total Time: ${formatTime(raceState.elapsedTime)}`;
const listEl = document.getElementById('results-standings');
listEl.innerHTML = '';
sorted.forEach((r, idx) => {
const row = document.createElement('div');
row.className = 'player-slot';
if (r.isLocal) row.style.borderColor = '#ffcc29';
const timeStr = r.finishTime > 0
? formatTime(r.finishTime)
: formatTime(raceState.elapsedTime + (idx * 1.42));
row.innerHTML = `
<span><strong>#${idx + 1}</strong> ${r.name} <small style="color:#8ad4ff;">(${r.planeData.name})</small></span>
<span style="color:#ffcc29;">${timeStr}</span>
`;
listEl.appendChild(row);
});
showScreen('results');
}
// ============================================================================
// 9. ONLINE MULTIPLAYER (SERVERLESS P2P WEBRTC + LOCAL TAB SYNC + COLYSEUS)
// ============================================================================
const mpState = {
clientId: 'p-' + Math.random().toString(36).substring(2, 8),
roomCode: '',
isHost: false,
ready: true,
mode: 'webrtc', // 'webrtc', 'cloud', or 'colyseus'
channel: typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('skyrace_3d_net_v2') : null,
ws: null,
peer: null,
hostConn: null,
clientConns: new Map(), // peerId -> DataConnection (used by Host)
players: new Map()
};
function setMpLog(msg, color = '#74f7a5') {
const el = document.getElementById('mp-status-log');
if (el) {
el.textContent = msg;
el.style.color = color;
}
}
function teardownPeerConnections() {
if (mpState.hostConn) {
try { mpState.hostConn.close(); } catch {}
mpState.hostConn = null;
}
mpState.clientConns.forEach((conn) => {
try { conn.close(); } catch {}
});
mpState.clientConns.clear();
if (mpState.peer) {
try { mpState.peer.destroy(); } catch {}
mpState.peer = null;
}
}
function refreshLobbyRosterUI() {
document.getElementById('mp-room-badge').textContent =
mpState.roomCode ? `CODE: ${mpState.roomCode}` : 'CODE: ----';
const listEl = document.getElementById('mp-roster-list');
listEl.innerHTML = '';
const entries = Array.from(mpState.players.values());
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'player-slot';
empty.innerHTML = `<span style="color:#9ecfff;">No active room. Click HOST ROOM or QUICK MATCH.</span>`;
listEl.appendChild(empty);
return;
}
entries.forEach((p) => {
const row = document.createElement('div');
row.className = 'player-slot';
const planeObj = PLANES_DATA.find((pl) => pl.id === p.planeId) || PLANES_DATA[0];
row.innerHTML = `
<span>${p.isHost ? '👑 ' : '✈️ '}<strong>${p.name}</strong> <small style="color:#8ad4ff;">(${planeObj.name})</small></span>
<span class="status-pill ${p.ready ? 'ready' : 'wait'}">${p.ready ? 'READY' : 'WAITING'}</span>
`;
listEl.appendChild(row);
});
}
function sendNetPacket(type, payload = {}) {
const packet = {
type,
roomCode: mpState.roomCode,
senderId: mpState.clientId,
payload
};
// Always mirror to BroadcastChannel for instant local tab testing
if (mpState.channel) {
mpState.channel.postMessage(packet);
}
if (mpState.mode === 'webrtc') {
if (mpState.isHost) {
// Host broadcasts to all connected WebRTC peers
mpState.clientConns.forEach((conn) => {
if (conn && conn.open) {
try { conn.send(packet); } catch {}
}
});
} else if (mpState.hostConn && mpState.hostConn.open) {
// Client sends to Host over WebRTC DataChannel
try { mpState.hostConn.send(packet); } catch {}
}
} else if (mpState.mode === 'colyseus' && mpState.ws && mpState.ws.readyState === WebSocket.OPEN) {
mpState.ws.send(JSON.stringify(packet));
}
}
function handleIncomingNetPacket(msg, fromConn = null) {
if (!msg || !msg.roomCode || msg.roomCode !== mpState.roomCode) return;
if (msg.type === 'JOIN_REQ') {
mpState.players.set(msg.senderId, msg.payload);
refreshLobbyRosterUI();
if (mpState.isHost) {
sendNetPacket('ROOM_STATE', {
routeId: document.getElementById('mp-route-select').value,
players: Array.from(mpState.players.values())
});
}
} else if (msg.type === 'ROOM_STATE') {
mpState.players.clear();
(msg.payload.players || []).forEach((p) => mpState.players.set(p.id, p));
if (msg.payload.routeId) {
document.getElementById('mp-route-select').value = msg.payload.routeId;
}
refreshLobbyRosterUI();
} else if (msg.type === 'START_RACE') {
const routeIdx = ROUTES_DATA.findIndex((r) => r.id === msg.payload.routeId);
if (routeIdx >= 0) appSettings.selectedRouteIndex = routeIdx;
start3DRaceSession(true, msg.payload.roster);
} else if (msg.type === 'TELEMETRY' && raceState && raceState.isMultiplayer) {
const t = msg.payload;
let remoteRacer = raceState.racers.find((r) => r.id === msg.senderId);
if (!remoteRacer && raceState.racers.length < 6) {
const pData = PLANES_DATA.find((p) => p.id === t.planeId) || PLANES_DATA[1];
const mesh = createLowPolyAirplaneMesh(pData);
worldRoot.add(mesh);
remoteRacer = {
id: msg.senderId,
name: t.name || 'PEER',
planeData: pData,
mesh,
isLocal: false,
isAI: false,
s: t.s || 0,
lap: t.lap || 1,
totalDistance: t.totalDistance || 0,
xOffset: t.xOffset || 0,
yOffset: t.yOffset || 0,
vx: 0, vy: 0,
speedKmh: t.speedKmh || 400,
boostEnergy: 100,
boosting: !!t.boosting,
bankAngle: t.bankAngle || 0,
nextGateIndex: 1,
finished: false,
finishTime: 0,
leftTrail: [], rightTrail: [],
leftLine: new THREE.Line(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0xffffff })),
rightLine: new THREE.Line(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0xffffff }))
};
worldRoot.add(remoteRacer.leftLine, remoteRacer.rightLine);
raceState.racers.push(remoteRacer);
}
if (remoteRacer && !remoteRacer.isLocal) {
remoteRacer.s = THREE.MathUtils.lerp(remoteRacer.s, t.s, 0.5);
remoteRacer.xOffset = THREE.MathUtils.lerp(remoteRacer.xOffset, t.xOffset, 0.5);
remoteRacer.yOffset = THREE.MathUtils.lerp(remoteRacer.yOffset, t.yOffset, 0.5);
remoteRacer.bankAngle = t.bankAngle;
remoteRacer.speedKmh = t.speedKmh;
remoteRacer.boosting = t.boosting;
remoteRacer.totalDistance = t.totalDistance;
remoteRacer.lap = t.lap;
}
// If we are WebRTC Host, relay this client's telemetry to all other clients
if (mpState.mode === 'webrtc' && mpState.isHost && fromConn) {
mpState.clientConns.forEach((conn) => {
if (conn !== fromConn && conn.open) {
try { conn.send(msg); } catch {}
}
});
}
}
}
if (mpState.channel) {
mpState.channel.onmessage = (ev) => handleIncomingNetPacket(ev.data);
}
function broadcastMultiplayerTelemetry(localRacer) {
sendNetPacket('TELEMETRY', {
name: localRacer.name,
planeId: localRacer.planeData.id,
s: localRacer.s,
xOffset: localRacer.xOffset,
yOffset: localRacer.yOffset,
bankAngle: localRacer.bankAngle,
speedKmh: localRacer.speedKmh,
boosting: localRacer.boosting,
totalDistance: localRacer.totalDistance,
lap: localRacer.lap
});
}
function getLocalPlayerSummary() {
const nameInput = document.getElementById('mp-pilot-name').value.trim() || appSettings.pilotName;
appSettings.pilotName = nameInput;
saveSettings(appSettings);
return {
id: mpState.clientId,
name: nameInput,
planeId: PLANES_DATA[appSettings.selectedPlaneIndex].id,
ready: mpState.ready,
isHost: mpState.isHost,
isAI: false
};
}
// Host or Join a Room (supports Serverless Internet WebRTC via PeerJS, Local Tab Sync, or Colyseus)
function joinOrCreateMultiplayerRoom(code, isHostFlag) {
mpState.mode = document.getElementById('mp-mode-select').value;
mpState.roomCode = code.toUpperCase();
mpState.isHost = isHostFlag;
mpState.players.clear();
const localPlayer = getLocalPlayerSummary();
mpState.players.set(mpState.clientId, localPlayer);
refreshLobbyRosterUI();
// 1. COLYSEUS WEBSOCKET MODE
if (mpState.mode === 'colyseus') {
teardownPeerConnections();
const url = document.getElementById('mp-server-url').value.trim();
setMpLog(`Connecting to Colyseus Server at ${url}...`, '#ffd54f');
try {
if (mpState.ws) mpState.ws.close();
mpState.ws = new WebSocket(url);
mpState.ws.onopen = () => {
setMpLog(`Connected to Colyseus Room [${mpState.roomCode}]!`, '#74f7a5');
sendNetPacket('JOIN_REQ', localPlayer);
};
mpState.ws.onmessage = (ev) => {
try { handleIncomingNetPacket(JSON.parse(ev.data)); } catch {}
};
mpState.ws.onerror = () => {
setMpLog(`Colyseus server unreachable at ${url}. Switch to Internet P2P (WebRTC) for zero-server play.`, '#ff8a80');
};
} catch {
setMpLog(`Invalid WebSocket URL (${url}).`, '#ff8a80');
}
return;
}
// 2. SERVERLESS INTERNET P2P WEBRTC MODE (PeerJS)
if (mpState.mode === 'webrtc' && typeof window.Peer !== 'undefined') {
teardownPeerConnections();
const hostPeerId = `skyrace3d-room-${mpState.roomCode}`;
if (isHostFlag) {
setMpLog(`Registering Internet P2P Room [${mpState.roomCode}] via WebRTC...`, '#ffd54f');
const peer = new window.Peer(hostPeerId);
mpState.peer = peer;
peer.on('open', () => {
setMpLog(`Room [${mpState.roomCode}] LIVE on Internet P2P! Share Code or click COPY INVITE LINK.`, '#74f7a5');
sendNetPacket('JOIN_REQ', localPlayer);
});
peer.on('connection', (conn) => {
mpState.clientConns.set(conn.peer, conn);
conn.on('data', (data) => handleIncomingNetPacket(data, conn));
conn.on('close', () => {
mpState.clientConns.delete(conn.peer);
});
});
peer.on('error', (err) => {
if (err && err.type === 'unavailable-id') {
// Room already exists on PeerJS broker -> join as client automatically!
mpState.isHost = false;
joinOrCreateMultiplayerRoom(mpState.roomCode, false);
} else {
setMpLog(`WebRTC Notice (${err.type || 'network'}): Same-PC BroadcastChannel sync active.`, '#ffd54f');
}
});
} else {
setMpLog(`Dialing Internet P2P Room [${mpState.roomCode}]...`, '#ffd54f');
const peer = new window.Peer();
mpState.peer = peer;
peer.on('open', () => {
const conn = peer.connect(hostPeerId, { reliable: true });
mpState.hostConn = conn;
conn.on('open', () => {
setMpLog(`Connected to Host in Room [${mpState.roomCode}] via WebRTC P2P!`, '#74f7a5');
sendNetPacket('JOIN_REQ', getLocalPlayerSummary());
});
conn.on('data', (data) => handleIncomingNetPacket(data, conn));
conn.on('close', () => {
setMpLog(`Host closed Room [${mpState.roomCode}].`, '#ff8a80');
});
});
peer.on('error', (err) => {
if (err && err.type === 'peer-unavailable' && mpState.roomCode === 'PUB01') {
// Quick match room not hosted yet -> become Host of PUB01!
setMpLog(`No active Quick Match host found — promoting you to Host of [PUB01]!`, '#74f7a5');
joinOrCreateMultiplayerRoom('PUB01', true);
} else {
setMpLog(`Could not find Room [${mpState.roomCode}] (${err.type}). Check the code or host a room.`, '#ff8a80');
}
});
}
return;
}
// 3. LOCAL MULTI-TAB SYNC FALLBACK
teardownPeerConnections();
sendNetPacket('JOIN_REQ', localPlayer);
setMpLog(`Room [${mpState.roomCode}] active on Local Multi-Tab Sync.`, '#74f7a5');
}
// Export standalone HTML file or Colyseus Server .TS
function downloadStandaloneGameHtml() {
audio.playUiConfirm();
const htmlContent = '<!DOCTYPE html>\n' + document.documentElement.outerHTML;
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sky_race_3d_multiplayer.html';
a.click();
URL.revokeObjectURL(url);
}
function exportColyseusServerCode() {
const code = `// SkyRaceRoom.ts — Colyseus Server-Authoritative 3D Multiplayer Room
import { Room, Client } from "@colyseus/core";
import { Schema, MapSchema, type } from "@colyseus/schema";
export class RacerState extends Schema {
@type("string") id: string = "";
@type("string") name: string = "ACE";
@type("string") planeId: string = "red-arrow";
@type("boolean") ready: boolean = true;
@type("number") s: number = 0;
@type("number") xOffset: number = 0;
@type("number") yOffset: number = 0;
@type("number") lap: number = 1;
@type("number") totalDistance: number = 0;
}
export class SkyRaceState extends Schema {
@type("string") routeId: string = "miami";
@type({ map: RacerState }) racers = new MapSchema<RacerState>();
}
export class SkyRaceRoom extends Room<SkyRaceState> {
maxClients = 6;
onCreate(options: any) {
this.setState(new SkyRaceState());
this.onMessage("TELEMETRY", (client, data) => {
const r = this.state.racers.get(client.sessionId);
if (r && typeof data.s === "number") {
r.s = data.s;
r.xOffset = Math.max(-18, Math.min(18, data.xOffset || 0));
r.yOffset = Math.max(-6, Math.min(9, data.yOffset || 0));
r.lap = data.lap || 1;
r.totalDistance = data.totalDistance || 0;
this.broadcast("TELEMETRY", { senderId: client.sessionId, payload: data }, { except: client });
}
});
}
onJoin(client: Client, options: any) {
const r = new RacerState();
r.id = client.sessionId;
r.name = (options?.name || "PILOT").slice(0, 12);
r.planeId = options?.planeId || "red-arrow";
this.state.racers.set(client.sessionId, r);
}
onLeave(client: Client) {
this.state.racers.delete(client.sessionId);
}
}
`;
const blob = new Blob([code], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'SkyRaceRoom.ts';
a.click();
URL.revokeObjectURL(url);
setMpLog('Downloaded Colyseus SkyRaceRoom.ts server room file!', '#74f7a5');
}
// ============================================================================
// 10. UI NAVIGATION, SCREEN TRANSITIONS & EVENT BINDING
// ============================================================================
const screenMap = {
menu: 'screen-menu',
planes: 'screen-planes',
routes: 'screen-routes',
multiplayer: 'screen-multiplayer',
racing: 'hud-screen',
pause: 'screen-pause',
results: 'screen-results',
options: 'screen-options'
};
function showScreen(targetState) {
audio.playUiClick();
Object.values(screenMap).forEach((id) => {
document.getElementById(id).classList.remove('active');
});
document.getElementById(screenMap[targetState]).classList.add('active');
currentAppState = targetState;
if (targetState === 'menu') {
audio.updateEnginePitch(0, false);
audio.startLoop('menu');
buildMenu3DDiorama();
} else if (targetState === 'planes') {
audio.updateEnginePitch(0, false);
audio.startLoop('menu');
buildHangar3DScene();
} else if (targetState === 'routes' || targetState === 'multiplayer' || targetState === 'options') {
audio.updateEnginePitch(0, false);
audio.startLoop('menu');
if (!menuAnimData) buildMenu3DDiorama();
} else if (targetState === 'pause') {
audio.updateEnginePitch(0, false);
}
}
function start3DRaceSession(isMultiplayer = false, customRoster = null) {
const route = ROUTES_DATA[appSettings.selectedRouteIndex];
audio.playUiConfirm();
audio.startLoop('race', route.musicRoot);
build3DRaceWorld(route, isMultiplayer, customRoster);
showScreen('racing');
}
function populatePlanesGridUI() {
const container = document.getElementById('plane-cards-container');
container.innerHTML = '';
PLANES_DATA.forEach((plane, idx) => {
const card = document.createElement('div');
card.className = 'plane-card' + (idx === appSettings.selectedPlaneIndex ? ' selected' : '');
const hexPrimary = '#' + plane.primary.toString(16).padStart(6, '0');
const hexSec = '#' + plane.secondary.toString(16).padStart(6, '0');
card.innerHTML = `
<div class="plane-check">✓</div>
<div class="plane-card-header">
<span>#${plane.num}</span>
<span class="plane-swatch" style="background:linear-gradient(135deg, ${hexPrimary} 55%, ${hexSec} 55%);"></span>
</div>
<div class="plane-card-name">${plane.num} ${plane.name}</div>
`;
card.addEventListener('click', () => {
audio.playUiClick();
updateHangarSelectedPlane(idx);
});
container.appendChild(card);
});
}
function populateRoutesGridUI() {
const container = document.getElementById('routes-grid-container');
const mpSelect = document.getElementById('mp-route-select');
container.innerHTML = '';
mpSelect.innerHTML = '';
ROUTES_DATA.forEach((route, idx) => {
const card = document.createElement('div');
card.className = 'route-card' + (idx === appSettings.selectedRouteIndex ? ' selected' : '');
card.innerHTML = `
<div class="route-title">${route.name}</div>
<div class="route-sub">${route.subtitle}</div>
<div class="route-badges">
<span class="route-badge">DIFF: ${route.difficulty}</span>
<span class="route-badge">WIND: ${Math.round(route.windForce * 100)}%</span>
<span class="route-badge">3 LAPS • 3D CIRCUIT</span>
</div>
`;
card.addEventListener('click', () => {
audio.playUiClick();
appSettings.selectedRouteIndex = idx;
saveSettings(appSettings);
document.querySelectorAll('.route-card').forEach((c, i) => c.classList.toggle('selected', i === idx));
});
card.addEventListener('dblclick', () => {
appSettings.selectedRouteIndex = idx;
start3DRaceSession(false);
});
container.appendChild(card);
const opt = document.createElement('option');
opt.value = route.id;
opt.textContent = route.name;
mpSelect.appendChild(opt);
});
}
populatePlanesGridUI();
populateRoutesGridUI();
document.getElementById('mp-mode-select').addEventListener('change', (e) => {
document.getElementById('colyseus-url-row').style.display =
e.target.value === 'colyseus' ? 'block' : 'none';
});
// Global Action Dispatcher
document.body.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.getAttribute('data-action');
switch (action) {
case 'nav-singleplayer':
showScreen('routes');
break;
case 'nav-multiplayer':
showScreen('multiplayer');
if (!mpState.roomCode) joinOrCreateMultiplayerRoom('SKY3D', true);
break;
case 'nav-planes':
planesReturnTarget = 'menu';
showScreen('planes');
break;
case 'nav-options':
showScreen('options');
break;
case 'planes-back':
case 'planes-confirm':
audio.playUiConfirm();
showScreen(planesReturnTarget);
break;
case 'routes-back':
showScreen('menu');
break;
case 'routes-change-plane':
planesReturnTarget = 'routes';
showScreen('planes');
break;
case 'routes-launch':
start3DRaceSession(false);
break;
case 'mp-back':
showScreen('menu');
break;
case 'mp-change-plane':
planesReturnTarget = 'multiplayer';
showScreen('planes');
break;
case 'mp-quick':
joinOrCreateMultiplayerRoom('PUB01', false);
break;
case 'mp-create': {
const randCode = 'SKY' + Math.floor(10 + Math.random() * 89);
document.getElementById('mp-room-code-input').value = randCode;
joinOrCreateMultiplayerRoom(randCode, true);
break;
}
case 'mp-join': {
const code = document.getElementById('mp-room-code-input').value.trim() || 'SKY3D';
joinOrCreateMultiplayerRoom(code, false);
break;
}
case 'mp-copy-invite': {
const code = mpState.roomCode || 'SKY3D';
const url = new URL(window.location.href);
url.searchParams.set('room', code);
navigator.clipboard?.writeText(url.toString()).then(() => {
setMpLog(`Copied Invite Link for Room [${code}]! Send it to your friends to auto-join.`, '#ffd54f');
}).catch(() => {
setMpLog(`Share Room Code [${code}] with your friends!`, '#ffd54f');
});
break;
}
case 'mp-toggle-ready': {
mpState.ready = !mpState.ready;
document.getElementById('btn-mp-ready').textContent = mpState.ready ? '✓ READY UP' : '⏳ NOT READY';
const me = getLocalPlayerSummary();
mpState.players.set(mpState.clientId, me);
refreshLobbyRosterUI();
sendNetPacket('JOIN_REQ', me);
break;
}
case 'mp-start-race': {
const chosenRouteId = document.getElementById('mp-route-select').value;
const rIdx = ROUTES_DATA.findIndex((r) => r.id === chosenRouteId);
if (rIdx >= 0) appSettings.selectedRouteIndex = rIdx;
const roster = Array.from(mpState.players.values());
let aiNum = 1;
while (roster.length < 6) {
const p = PLANES_DATA[(appSettings.selectedPlaneIndex + aiNum * 2) % PLANES_DATA.length];
roster.push({
id: `ai-mp-${aiNum}`,
name: `${p.name} [AI]`,
planeId: p.id,
ready: true,
isAI: true
});
aiNum++;
}
sendNetPacket('START_RACE', { routeId: chosenRouteId, roster });
start3DRaceSession(true, roster);
break;
}
case 'mp-export-server':
exportColyseusServerCode();
break;
case 'race-pause':
showScreen('pause');
break;
case 'pause-resume':
showScreen('racing');
break;
case 'pause-restart':
start3DRaceSession(raceState ? raceState.isMultiplayer : false);
break;
case 'pause-quit':
showScreen('menu');
break;
case 'results-rematch':
start3DRaceSession(raceState ? raceState.isMultiplayer : false);
break;
case 'results-routes':
showScreen('routes');
break;
case 'results-menu':
showScreen('menu');
break;
case 'options-close':
saveSettings(appSettings);
showScreen('menu');
break;
case 'options-reset':
Object.assign(appSettings, DEFAULT_SETTINGS);
saveSettings(appSettings);
syncOptionsUI();
break;
case 'options-mute':
appSettings.muted = !appSettings.muted;
saveSettings(appSettings);
syncOptionsUI();
break;
}
});
// Options Sliders
function syncOptionsUI() {
document.getElementById('opt-music').value = appSettings.musicVolume;
document.getElementById('lbl-opt-music').textContent = `${appSettings.musicVolume}%`;
document.getElementById('opt-sfx').value = appSettings.sfxVolume;
document.getElementById('lbl-opt-sfx').textContent = `${appSettings.sfxVolume}%`;
document.getElementById('opt-sens').value = Math.round(appSettings.sensitivity * 100);
document.getElementById('lbl-opt-sens').textContent = `${appSettings.sensitivity.toFixed(1)}x`;
document.getElementById('opt-diff').value = String(appSettings.aiDifficulty);
document.getElementById('btn-opt-mute').textContent = `MUTE ALL: ${appSettings.muted ? 'ON' : 'OFF'}`;
document.getElementById('btn-mute-quick').textContent = appSettings.muted ? '🔇 AUDIO: OFF' : '🔊 AUDIO: ON';
document.getElementById('mp-pilot-name').value = appSettings.pilotName;
}
syncOptionsUI();
document.getElementById('opt-music').addEventListener('input', (e) => {
appSettings.musicVolume = Number(e.target.value);
syncOptionsUI();
});
document.getElementById('opt-sfx').addEventListener('input', (e) => {
appSettings.sfxVolume = Number(e.target.value);
syncOptionsUI();
});
document.getElementById('opt-sens').addEventListener('input', (e) => {
appSettings.sensitivity = Number(e.target.value) / 100;
syncOptionsUI();
});
document.getElementById('opt-diff').addEventListener('change', (e) => {
appSettings.aiDifficulty = Number(e.target.value);
saveSettings(appSettings);
});
document.getElementById('btn-mute-quick').addEventListener('click', () => {
appSettings.muted = !appSettings.muted;
saveSettings(appSettings);
syncOptionsUI();
});
function cycleCameraMode() {
cameraModeIndex = (cameraModeIndex + 1) % 3;
const labels = ['🎥 CAM: CHASE', '🎥 CAM: FAR', '🎥 CAM: NOSE'];
document.getElementById('btn-cam-mode').textContent = labels[cameraModeIndex];
}
document.getElementById('btn-cam-mode').addEventListener('click', cycleCameraMode);
document.getElementById('btn-download-html').addEventListener('click', downloadStandaloneGameHtml);
// Self-Verification Suite
document.getElementById('btn-self-test').addEventListener('click', () => {
const checks = [
PLANES_DATA.length === 10,
ROUTES_DATA.length === 7,
renderer instanceof THREE.WebGLRenderer,
typeof createLowPolyAirplaneMesh === 'function'
];
const passed = checks.every(Boolean);
const btn = document.getElementById('btn-self-test');
btn.textContent = passed ? '✔ 3D + P2P OK (10P/7R)' : '✖ TEST ERR';
btn.style.borderColor = passed ? '#35e86b' : '#ff3b2f';
audio.playUiConfirm();
});
// ============================================================================
// 11. MASTER 3D ANIMATION & RENDER LOOP + URL INVITE AUTO-JOIN
// ============================================================================
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
let lastFrameTime = performance.now();
function animate(now) {
requestAnimationFrame(animate);
const dt = Math.min(0.05, (now - lastFrameTime) / 1000);
lastFrameTime = now;
if (currentAppState === 'menu' || currentAppState === 'routes' || currentAppState === 'multiplayer' || currentAppState === 'options') {
if (menuAnimData) {
menuAnimData.t += dt;
const { heroPlane, rivalBlue, rivalYellow, clouds, t } = menuAnimData;
heroPlane.position.y = -1.8 + Math.sin(t * 2.2) * 0.35;
heroPlane.rotation.z = -0.42 + Math.cos(t * 1.8) * 0.06;
if (heroPlane.userData.propeller) heroPlane.userData.propeller.rotation.z += dt * 38;
rivalBlue.position.x = 11 - ((t * 4.5) % 28);
rivalBlue.position.y = 5.2 + Math.sin(t * 2.5) * 0.4;
if (rivalBlue.userData.propeller) rivalBlue.userData.propeller.rotation.z += dt * 36;
rivalYellow.position.x = 14 - ((t * 3.8) % 30);
if (rivalYellow.userData.propeller) rivalYellow.userData.propeller.rotation.z += dt * 34;
clouds.forEach((c) => {
c.position.x += dt * 1.6;
if (c.position.x > 95) c.position.x = -95;
});
}
} else if (currentAppState === 'planes') {
if (hangarAnimData && hangarAnimData.pivot) {
if (!hangarAnimData.isDragging) {
hangarAnimData.rotY += dt * 0.55;
}
hangarAnimData.pivot.rotation.y = hangarAnimData.rotY;
if (hangarAnimData.planeMesh && hangarAnimData.planeMesh.userData.propeller) {
hangarAnimData.planeMesh.userData.propeller.rotation.z += dt * 18;
}
}
} else if (currentAppState === 'racing') {
updateRaceSimulation(dt);
}
renderer.render(scene, camera);
}
// Initialize 3D Main Menu Diorama
buildMenu3DDiorama();
// Check if URL contains ?room=CODE invite parameter for instant multiplayer auto-join
const urlParams = new URLSearchParams(window.location.search);
const invitedRoom = urlParams.get('room');
if (invitedRoom) {
const cleanCode = invitedRoom.trim().toUpperCase().slice(0, 6);
document.getElementById('mp-room-code-input').value = cleanCode;
showScreen('multiplayer');
joinOrCreateMultiplayerRoom(cleanCode, false);
}
requestAnimationFrame(animate);
</script>
</body>
</html>