Battle Bus x Simpsons 3D
by LunarGalaxy87514 lines15.7 KB🛠️ Three.js (3D graphics)
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Battle Bus x Simpsons 3D</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#4aa8e0;font-family:sans-serif;}
#info{position:fixed;bottom:70px;left:0;right:0;text-align:center;color:#fff;opacity:.7;font-size:13px;pointer-events:none;}
canvas{display:block;touch-action:none;}
#playBtn{
position:fixed;bottom:16px;left:50%;transform:translateX(-50%);
z-index:50;padding:14px 28px;font-size:16px;font-weight:bold;
background:#f7d117;color:#3a1a06;border:3px solid #3a1a06;border-radius:10px;
box-shadow:0 4px 0 #3a1a06; -webkit-tap-highlight-color: transparent;
}
#playBtn:active{transform:translateX(-50%) translateY(3px);box-shadow:0 1px 0 #3a1a06;}
</style>
</head>
<body>
<div id="info">tap the button for sound + replay 🔊</div>
<button id="playBtn">▶ Play with Sound</button>
<div id="err" style="position:fixed;top:0;left:0;right:0;background:#b00020;color:#fff;font-size:12px;padding:8px;display:none;white-space:pre-wrap;z-index:99;"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" crossorigin="anonymous"></script>
<script>
window.addEventListener('error', function(e){
const box = document.getElementById('err');
box.style.display = 'block';
box.textContent = 'Error: ' + (e.message || 'unknown') +
(e.filename ? ('\n' + e.filename + ':' + e.lineno + ':' + e.colno) : '');
});
</script>
<script>
let scene, camera, renderer, clock;
let bus, balloons = [], busGroup;
let logoGroup, oMesh;
let logoReady = false;
let startTime = null;
const duration = 5.2; // seconds
let playing = false;
let clouds = [];
// ---- audio ----
let actx = null;
let engineNodes = null;
let audioUnlocked = false;
function unlockAudio(){
if (!actx){
actx = new (window.AudioContext || window.webkitAudioContext)();
}
if (actx.state === 'suspended'){
actx.resume();
}
audioUnlocked = true;
}
function makeNoiseBuffer(duration){
const rate = actx.sampleRate;
const buf = actx.createBuffer(1, rate*duration, rate);
const data = buf.getChannelData(0);
for (let i=0;i<data.length;i++) data[i] = Math.random()*2-1;
return buf;
}
function playEngine(){
if (!actx) return;
stopEngine();
const o1 = actx.createOscillator();
const o2 = actx.createOscillator();
const gain = actx.createGain();
o1.type = 'sawtooth';
o2.type = 'square';
o1.frequency.value = 55;
o2.frequency.value = 58;
const filter = actx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 400;
gain.gain.value = 0.0001;
o1.connect(filter); o2.connect(filter);
filter.connect(gain);
gain.connect(actx.destination);
o1.start(); o2.start();
gain.gain.linearRampToValueAtTime(0.05, actx.currentTime + 0.3);
engineNodes = {o1,o2,gain,filter};
}
function stopEngine(){
if (engineNodes){
const {o1,o2,gain} = engineNodes;
try{
gain.gain.linearRampToValueAtTime(0.0001, actx.currentTime + 0.4);
o1.stop(actx.currentTime + 0.45);
o2.stop(actx.currentTime + 0.45);
}catch(e){}
engineNodes = null;
}
}
function playWhoosh(startAt, dur){
if (!actx) return;
const t0 = actx.currentTime + startAt;
const noise = actx.createBufferSource();
noise.buffer = makeNoiseBuffer(dur);
const filter = actx.createBiquadFilter();
filter.type = 'bandpass';
filter.Q.value = 0.7;
filter.frequency.setValueAtTime(200, t0);
filter.frequency.linearRampToValueAtTime(2200, t0 + dur*0.6);
filter.frequency.linearRampToValueAtTime(600, t0 + dur);
const gain = actx.createGain();
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.linearRampToValueAtTime(0.35, t0 + dur*0.4);
gain.gain.linearRampToValueAtTime(0.0001, t0 + dur);
noise.connect(filter); filter.connect(gain); gain.connect(actx.destination);
noise.start(t0);
noise.stop(t0 + dur + 0.05);
}
function playImpact(startAt){
if (!actx) return;
const t0 = actx.currentTime + startAt;
// low thud
const osc = actx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(140, t0);
osc.frequency.exponentialRampToValueAtTime(35, t0 + 0.35);
const oGain = actx.createGain();
oGain.gain.setValueAtTime(0.6, t0);
oGain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.4);
osc.connect(oGain); oGain.connect(actx.destination);
osc.start(t0); osc.stop(t0 + 0.45);
// crash noise burst
const noise = actx.createBufferSource();
noise.buffer = makeNoiseBuffer(0.4);
const nFilter = actx.createBiquadFilter();
nFilter.type = 'highpass';
nFilter.frequency.value = 800;
const nGain = actx.createGain();
nGain.gain.setValueAtTime(0.5, t0);
nGain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.35);
noise.connect(nFilter); nFilter.connect(nGain); nGain.connect(actx.destination);
noise.start(t0); noise.stop(t0 + 0.4);
}
function playFanfare(startAt){
if (!actx) return;
const t0 = actx.currentTime + startAt;
// original upbeat brass-y cartoon fanfare (not any existing theme) - bouncy staccato notes
const notes = [
{f:392.00, t:0.00, d:0.14},
{f:392.00, t:0.16, d:0.14},
{f:523.25, t:0.32, d:0.20},
{f:466.16, t:0.54, d:0.12},
{f:523.25, t:0.68, d:0.28},
{f:659.25, t:1.00, d:0.30},
];
notes.forEach(n=>{
const o = actx.createOscillator();
o.type = 'square';
o.frequency.value = n.f;
const g = actx.createGain();
const nt0 = t0 + n.t;
g.gain.setValueAtTime(0.0001, nt0);
g.gain.linearRampToValueAtTime(0.12, nt0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, nt0 + n.d);
o.connect(g); g.connect(actx.destination);
o.start(nt0); o.stop(nt0 + n.d + 0.02);
});
}
function playHonk(startAt){
if (!actx) return;
const t0 = actx.currentTime + startAt;
[220, 277].forEach((freq, i)=>{
const o = actx.createOscillator();
o.type = 'square';
o.frequency.value = freq;
const g = actx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.linearRampToValueAtTime(0.08, t0+0.05);
g.gain.linearRampToValueAtTime(0.0001, t0+0.35);
o.connect(g); g.connect(actx.destination);
o.start(t0); o.stop(t0+0.4);
});
}
function init(){
scene = new THREE.Scene();
// sky gradient via fog + clear color
scene.background = new THREE.Color(0x4aa8e0);
scene.fog = new THREE.Fog(0x9fd6f0, 200, 1400);
camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 1, 3000);
camera.position.set(0, 40, 340);
camera.lookAt(0,40,0);
renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
// lights
const hemi = new THREE.HemisphereLight(0xffffff, 0x6699cc, 0.9);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2d0, 1.1);
sun.position.set(150, 300, 200);
sun.castShadow = true;
scene.add(sun);
buildClouds();
buildBus();
buildLogo();
logoReady = true;
play();
window.addEventListener('resize', onResize);
const btn = document.getElementById('playBtn');
const trigger = (e)=>{
if (e) e.preventDefault();
unlockAudio();
if (logoReady) play();
const dbg = document.getElementById('err');
if (actx){
dbg.style.display = 'block';
dbg.style.background = '#1a5';
dbg.textContent = 'Audio context state: ' + actx.state + ' (sampleRate: ' + actx.sampleRate + ')';
setTimeout(()=>{ dbg.style.display='none'; }, 2500);
}
};
btn.addEventListener('click', trigger);
btn.addEventListener('touchend', trigger);
renderer.domElement.addEventListener('click', trigger);
animate();
}
function onResize(){
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
}
function buildClouds(){
const cloudMat = new THREE.MeshStandardMaterial({color:0xffffff, roughness:1});
for (let i=0;i<26;i++){
const g = new THREE.Group();
const n = 3+Math.floor(Math.random()*3);
for (let j=0;j<n;j++){
const s = 14+Math.random()*16;
const sph = new THREE.Mesh(new THREE.SphereGeometry(s,8,8), cloudMat);
sph.position.set((Math.random()-0.5)*40, (Math.random()-0.5)*10, (Math.random()-0.5)*20);
g.add(sph);
}
g.position.set((Math.random()-0.5)*1400, 60+Math.random()*220, -Math.random()*1200+100);
g.scale.setScalar(0.6+Math.random()*0.8);
scene.add(g);
clouds.push(g);
}
}
function buildBus(){
busGroup = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({color:0x2f6fb0, roughness:0.5, metalness:0.1});
const stripeMat = new THREE.MeshStandardMaterial({color:0xf2c53d, roughness:0.6});
const windowMat = new THREE.MeshStandardMaterial({color:0xcfeeff, roughness:0.2, metalness:0.3});
const darkMat = new THREE.MeshStandardMaterial({color:0x16324f, roughness:0.8});
// main body
const body = new THREE.Mesh(new THREE.BoxGeometry(60,34,110,2,2,2), bodyMat);
body.castShadow = true;
busGroup.add(body);
// rounded front/back caps using spheres squashed
const cap1 = new THREE.Mesh(new THREE.SphereGeometry(17,12,12), bodyMat);
cap1.scale.set(1,1,0.3);
cap1.position.set(0,0,55);
busGroup.add(cap1);
const cap2 = cap1.clone();
cap2.position.z = -55;
busGroup.add(cap2);
// stripe
const stripe = new THREE.Mesh(new THREE.BoxGeometry(61,8,111), stripeMat);
stripe.position.y = 4;
busGroup.add(stripe);
// windows (row along both sides)
for (let s=-1;s<=1;s+=2){
for (let i=-3;i<=3;i++){
const win = new THREE.Mesh(new THREE.BoxGeometry(2,10,12), windowMat);
win.position.set(s*30.5, 10, i*14);
busGroup.add(win);
}
}
// windshield front/back
const wf = new THREE.Mesh(new THREE.BoxGeometry(50,14,2), windowMat);
wf.position.set(0,10,60);
busGroup.add(wf);
const wb = wf.clone(); wb.position.z=-60; busGroup.add(wb);
// underside dark
const belly = new THREE.Mesh(new THREE.BoxGeometry(58,10,108), darkMat);
belly.position.y = -20;
busGroup.add(belly);
// ropes + balloons
const ropeMat = new THREE.MeshStandardMaterial({color:0x555555});
const balloonMat = new THREE.MeshStandardMaterial({color:0x3aa1e0, roughness:0.3, metalness:0.1});
const positions = [
[-24,60,40],[24,60,40],
[-24,66,0],[24,66,0],
[-24,60,-40],[24,60,-40],
];
positions.forEach(p=>{
const rope = new THREE.Mesh(new THREE.CylinderGeometry(0.6,0.6,60,6), ropeMat);
rope.position.set(p[0], (p[1]+17)/2, p[2]);
busGroup.add(rope);
const balloon = new THREE.Mesh(new THREE.SphereGeometry(16,16,16), balloonMat);
balloon.position.set(p[0], p[1], p[2]);
balloon.castShadow = true;
busGroup.add(balloon);
balloons.push(balloon);
});
bus = busGroup;
bus.rotation.y = Math.PI; // face forward towards camera
scene.add(bus);
}
// 5x7 dot-matrix patterns for a chunky blocky 3D font (no external font files needed)
const PIXEL_FONT = {
S: ["01111","10000","10000","01110","00001","00001","11110"],
I: ["11111","00100","00100","00100","00100","00100","11111"],
M: ["10001","11011","10101","10101","10001","10001","10001"],
P: ["11110","10001","10001","11110","10000","10000","10000"],
O: ["01110","10001","10001","10001","10001","10001","01110"],
N: ["10001","11001","10101","10101","10011","10001","10001"]
};
function buildLogo(){
logoGroup = new THREE.Group();
const word = "SIMPSONS";
const cell = 13; // pixel block size
const depth = 26; // extrusion depth (3D thickness)
const letterGap = cell*1.4;
const yellowMat = new THREE.MeshStandardMaterial({color:0xf7d117, roughness:0.4, metalness:0.1});
const outlineMat = new THREE.MeshStandardMaterial({color:0x3a1a06, roughness:0.6});
const blockGeo = new THREE.BoxGeometry(cell*0.92, cell*0.92, depth);
const outlineGeo = new THREE.BoxGeometry(cell*1.04, cell*1.04, depth*1.04);
const letterWidth = 5*cell;
const totalWidth = word.length*letterWidth + (word.length-1)*letterGap;
let cursorX = -totalWidth/2;
word.split('').forEach(ch=>{
const pattern = PIXEL_FONT[ch];
const letterGroup = new THREE.Group();
for (let row=0; row<7; row++){
for (let col=0; col<5; col++){
if (pattern[row][col] === '1'){
const px = col*cell;
const py = (6-row)*cell; // flip so row 0 is top
const outline = new THREE.Mesh(outlineGeo, outlineMat);
outline.position.set(px, py, 0);
letterGroup.add(outline);
const fill = new THREE.Mesh(blockGeo, yellowMat);
fill.position.set(px, py, 0);
fill.castShadow = true;
letterGroup.add(fill);
}
}
}
letterGroup.position.x = cursorX;
logoGroup.add(letterGroup);
if (ch === 'O' && !oMesh){
oMesh = letterGroup;
oMesh.userData.centerX = cursorX + letterWidth/2;
}
cursorX += letterWidth + letterGap;
});
// center vertically around row height
logoGroup.children.forEach(g=> g.position.y -= 3*cell);
logoGroup.position.set(0, 50, -400);
scene.add(logoGroup);
}
function play(){
playing = true;
startTime = null;
if (oMesh) { oMesh.scale.set(1,1,1); oMesh.rotation.z = 0; }
if (actx){
playEngine();
playFanfare(0);
playHonk(1.3);
playWhoosh(0.1, duration*0.55);
playImpact(duration*0.58);
playWhoosh(duration*0.66, duration*0.3);
setTimeout(stopEngine, duration*1000 + 200);
}
}
function lerp(a,b,t){return a+(b-a)*t;}
function easeIn(t){return t*t;}
function easeOut(t){return 1-(1-t)*(1-t);}
function easeInOut(t){return t<0.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2;}
function animate(){
requestAnimationFrame(animate);
const dt = clock ? clock.getDelta() : 0.016;
if (!clock) clock = new THREE.Clock();
clouds.forEach(c=>{
c.position.x += 6*dt;
if (c.position.x > 800) c.position.x = -800;
});
balloons.forEach((b,i)=>{
b.position.y += Math.sin(Date.now()*0.002 + i)*0.02;
});
if (playing && logoReady){
if (startTime === null) startTime = performance.now()/1000;
const t = performance.now()/1000 - startTime;
const p = Math.min(t/duration, 1);
const oX = oMesh.userData.centerX;
const oWorldX = logoGroup.position.x + oX;
const oWorldY = logoGroup.position.y;
const oWorldZ = logoGroup.position.z;
const startZ = 900, startY = 90, startX = -260;
let bx,by,bz,brotY,bscale;
if (p < 0.55){
const lp = easeIn(p/0.55);
bx = lerp(startX, oWorldX, lp);
by = lerp(startY, oWorldY, lp);
bz = lerp(startZ, oWorldZ+10, lp);
brotY = Math.PI + lerp(-0.3,0,lp);
bscale = lerp(1.4, 1.0, lp);
} else if (p < 0.68){
const lp = (p-0.55)/0.13;
bx = oWorldX;
by = oWorldY;
bz = lerp(oWorldZ+10, oWorldZ-10, lp);
brotY = Math.PI;
bscale = 1.0;
// impact wobble on O
const bump = Math.sin(lp*Math.PI);
oMesh.scale.set(1+bump*0.3, 1+bump*0.3, 1+bump*0.3);
oMesh.rotation.z = Math.sin(lp*Math.PI*2)*0.15;
// camera shake
camera.position.x += (Math.random()-0.5)*bump*3;
camera.position.y += (Math.random()-0.5)*bump*3;
} else {
const lp = easeOut((p-0.68)/0.32);
bx = lerp(oWorldX, oWorldX*0.3, lp);
by = lerp(oWorldY, oWorldY+60, lp);
bz = lerp(oWorldZ-10, camera.position.z+120, lp);
brotY = Math.PI + lerp(0,0.4,lp);
bscale = lerp(1.0, 0.7, lp);
if (oMesh.scale.x > 1){
oMesh.scale.multiplyScalar(0.9);
oMesh.rotation.z *= 0.85;
}
}
bus.position.set(bx,by,bz);
bus.rotation.y = brotY;
bus.scale.setScalar(bscale);
if (p >= 1){
playing = false;
bus.position.set(startX, startY, startZ);
oMesh.scale.set(1,1,1);
oMesh.rotation.z = 0;
}
}
renderer.render(scene, camera);
}
try {
init();
} catch(e){
const box = document.getElementById('err');
box.style.display = 'block';
box.textContent = 'Init error: ' + e.message;
console.error(e);
}
</script>
</body>
</html>
Game Source: Battle Bus x Simpsons 3D
Creator: LunarGalaxy87
Libraries: three
Complexity: complex (514 lines, 15.7 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: battle-bus-x-simpsons-3d-lunargalaxy87" to link back to the original. Then publish at arcadelab.ai/publish.