game / index.html
mlmihjaz
Deploy trimmed 3D Magic Forest as static Space
12e39ff
Raw
History Blame Contribute Delete
44.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Magic Forest – Learn & Play!</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { overflow: hidden; background: #87CEEB; font-family: 'Comic Sans MS', 'Baloo', system-ui, sans-serif; touch-action: none; }
canvas { display: block; }
#score {
position: absolute; top: 14px; right: 78px; z-index: 5;
background: rgba(0,0,0,0.4); color: #ffe34d; padding: 10px 18px;
border-radius: 16px; font-size: 20px; font-weight: bold; pointer-events: none; letter-spacing: .5px;
}
#soundbtn {
position: absolute; top: 14px; right: 16px; z-index: 6;
background: rgba(0,0,0,0.4); border: 2px solid rgba(255,255,255,.5); border-radius: 14px;
width: 50px; height: 46px; display: flex; align-items: center; justify-content: center;
font-size: 24px; cursor: pointer; user-select: none;
}
#info {
position: absolute; top: 118px; left: 50%; transform: translateX(-50%); z-index: 5;
background: rgba(0,0,0,0.4); color: #fff; padding: 7px 20px; border-radius: 22px;
font-size: 16px; pointer-events: none; max-width: 92vw; text-align: center;
}
#coords { position: absolute; bottom: 10px; right: 12px; color: rgba(255,255,255,.7);
font-size: 11px; font-family: monospace; pointer-events: none; }
/* ── LEVEL BADGE + TASK PANEL ── */
#levelbadge {
position: absolute; top: 14px; left: 16px; z-index: 5;
background: #2d5a1b; color: #fff; padding: 10px 16px; border-radius: 16px;
font-size: 18px; font-weight: bold; box-shadow: 0 4px 0 rgba(0,0,0,.2); pointer-events: none;
}
#task {
position: absolute; top: 10px; left: 50%; transform: translateX(-50%); z-index: 5;
background: rgba(0,0,0,0.45); border: 3px solid #ffd23f; border-radius: 20px;
padding: 8px 20px 12px; text-align: center; pointer-events: none; max-width: 94vw;
}
#task-title { color: #ffd23f; font-size: 15px; font-weight: bold; letter-spacing: .5px; margin-bottom: 6px; }
#task-body { display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; }
.bignum {
display: inline-flex; align-items: center; justify-content: center; width: 50px; height: 50px;
background: #fff; color: #ff8a00; border-radius: 50%; font-size: 30px; font-weight: bold;
box-shadow: 0 3px 0 rgba(0,0,0,.25);
}
.slot {
display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 46px;
background: rgba(255,255,255,.15); border: 2px dashed #9fd; border-radius: 10px;
color: #cfefff; font-size: 26px; font-weight: bold;
}
.slot.done { background: #4caf50; border-style: solid; border-color: #fff; color: #fff; }
.slot.next { border-color: #ffd23f; border-style: solid; color: #fff; animation: pulse 0.8s ease-in-out infinite; }
.word { color: #fff; font-size: 26px; font-weight: bold; letter-spacing: 2px; }
/* ── ON-SCREEN ARROWS (touch) ── */
#pad { position: absolute; bottom: 22px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 8px; z-index: 5; user-select: none; }
.key-row { display: flex; gap: 8px; }
.key {
width: 60px; height: 60px; background: rgba(255,255,255,0.3); border: 2px solid rgba(255,255,255,0.6);
border-radius: 14px; display: flex; align-items: center; justify-content: center;
font-size: 26px; color: #fff; backdrop-filter: blur(4px); cursor: pointer;
}
.key.active { background: rgba(255,255,255,0.6); color: #2d5a1b; }
/* ── OVERLAYS ── */
#loader, #start {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 18px; color: #fff; z-index: 20; text-align: center; padding: 20px;
background: radial-gradient(circle at 50% 35%, #5aa9e6 0%, #1f4e79 100%);
}
#loader h1, #start h1 { font-size: 34px; text-shadow: 0 3px 0 rgba(0,0,0,.25); }
#start { display: none; }
#start p { font-size: 18px; opacity: .95; }
.spinner { width: 54px; height: 54px; border: 6px solid rgba(255,255,255,0.25);
border-top-color: #ffe34d; border-radius: 50%; animation: spin .9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
#start-btn {
margin-top: 8px; padding: 18px 46px; font-size: 26px; font-weight: bold; font-family: inherit;
border: none; border-radius: 40px; background: #ffd23f; color: #2d5a1b; cursor: pointer;
box-shadow: 0 6px 0 #c79a14; animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{ transform: scale(1);} 50%{ transform: scale(1.07);} }
.emoji { font-size: 56px; }
</style>
</head>
<body>
<div id="loader">
<div class="spinner"></div>
<h1>Loading the magic forest…</h1>
<p id="loadtext">Fetching models…</p>
</div>
<div id="start">
<div class="emoji">🦊🌳⭐</div>
<h1>Magic Forest<br>Learn &amp; Play!</h1>
<p>Numbers, letters &amp; shiny coins!</p>
<button id="start-btn">▶ TAP TO START</button>
</div>
<div id="levelbadge">Level 1</div>
<div id="task">
<div id="task-title">Get ready!</div>
<div id="task-body"></div>
</div>
<div id="soundbtn">🔊</div>
<div id="score">★ 0</div>
<div id="info">Tap to start!</div>
<div id="coords">x:0 z:0</div>
<div id="pad">
<div class="key-row"><div class="key" data-k="ArrowUp"></div></div>
<div class="key-row">
<div class="key" data-k="ArrowLeft"></div>
<div class="key" data-k="ArrowDown"></div>
<div class="key" data-k="ArrowRight"></div>
</div>
<div class="key-row"><div class="key" id="jump-btn" style="width:auto;padding:0 20px;font-size:18px;font-weight:bold">JUMP ⤒</div></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://unpkg.com/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
<script src="libs/Sky.js"></script>
<script>
// ════════════════════ SCENE ════════════════════
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xcfe8f5); // refined by the Sky block below
scene.fog = new THREE.Fog(0xcfe8f5, 80, 260);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.7;
document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 60000);
const CAM_OFFSET = new THREE.Vector3(0, 5.5, 10.5);
scene.add(new THREE.AmbientLight(0xfff5e0, 0.75));
const sunLight = new THREE.DirectionalLight(0xfff0cc, 1.05);
sunLight.position.set(20, 42, 12); sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.near = 0.5; sunLight.shadow.camera.far = 120;
Object.assign(sunLight.shadow.camera, { left: -50, right: 50, top: 50, bottom: -50 });
scene.add(sunLight);
scene.add(new THREE.HemisphereLight(0x87CEEB, 0x4a7c30, 0.5));
// ── COLORFUL SUNNY SKY (vivid gradient dome + bright sun glow) ──
const SUN_ELEV = 38, SUN_AZIM = 150;
const sunV = new THREE.Vector3().setFromSphericalCoords(
1, THREE.MathUtils.degToRad(90 - SUN_ELEV), THREE.MathUtils.degToRad(SUN_AZIM));
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false, toneMapped: false,
uniforms: {
uTop: { value: new THREE.Color(0x1466e0) }, // deep blue zenith
uMid: { value: new THREE.Color(0x4fb0ff) }, // bright sky blue
uBot: { value: new THREE.Color(0xeaf8ff) }, // pale sunny horizon
uSun: { value: sunV.clone() },
uSunCol: { value: new THREE.Color(0xfff0a8) } // warm sun
},
vertexShader: `varying vec3 vDir; void main(){ vDir = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `varying vec3 vDir; uniform vec3 uTop,uMid,uBot,uSun,uSunCol;
void main(){
float h = clamp(vDir.y, 0.0, 1.0);
vec3 col = mix(uMid, uTop, pow(h, 0.65));
col = mix(uBot, col, smoothstep(0.0, 0.20, vDir.y + 0.06)); // pale horizon band
float s = max(dot(normalize(vDir), normalize(uSun)), 0.0);
col += uSunCol * pow(s, 500.0) * 2.0; // bright sun disc
col += uSunCol * pow(s, 10.0) * 0.35; // warm glow
gl_FragColor = vec4(col, 1.0);
}`
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(4000, 40, 20), skyMat); scene.add(sky);
sunLight.position.copy(sunV).multiplyScalar(80); // light comes from the visible sun
scene.fog.color.setHex(0xeaf8ff); // haze matches the sunny horizon
scene.background = new THREE.Color(0xbfe6ff);
// ground
const groundGeo = new THREE.PlaneGeometry(600, 600, 48, 48);
const pa = groundGeo.attributes.position;
for (let i = 0; i < pa.count; i++) { const x = pa.getX(i), z = pa.getY(i);
if (Math.sqrt(x*x+z*z) > 3) pa.setZ(i, (Math.random()-0.5)*0.4); }
groundGeo.computeVertexNormals();
const ground = new THREE.Mesh(groundGeo, new THREE.MeshLambertMaterial({ color: 0x4a8c2a }));
ground.rotation.x = -Math.PI/2; ground.receiveShadow = true; scene.add(ground);
// ════════════════════ TEXTURES (glow / sparkle) ════════════════════
function softCircleTexture() {
const c = document.createElement('canvas'); c.width = c.height = 128;
const g = c.getContext('2d');
const grd = g.createRadialGradient(64, 64, 0, 64, 64, 64);
grd.addColorStop(0, 'rgba(255,255,255,1)');
grd.addColorStop(0.3, 'rgba(255,255,255,0.8)');
grd.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = grd; g.fillRect(0, 0, 128, 128);
return new THREE.CanvasTexture(c);
}
const GLOW_TEX = softCircleTexture();
function makeLabel(text, hex) {
const c = document.createElement('canvas'); c.width = c.height = 256;
const g = c.getContext('2d');
g.beginPath(); g.arc(128, 128, 108, 0, Math.PI*2);
g.fillStyle = hex; g.fill();
g.lineWidth = 14; g.strokeStyle = '#ffffff'; g.stroke();
g.fillStyle = '#ffffff'; g.font = 'bold 150px "Comic Sans MS", Arial';
g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillText(text, 128, 140);
const tex = new THREE.CanvasTexture(c);
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }));
spr.scale.set(1.7, 1.7, 1);
return spr;
}
function glowSprite(hex) {
const m = new THREE.SpriteMaterial({ map: GLOW_TEX, color: new THREE.Color(hex),
transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false });
const s = new THREE.Sprite(m); s.scale.set(2.4, 2.4, 1); return s;
}
function sparkleCloud(hex) {
const N = 10, pos = new Float32Array(N*3);
for (let i = 0; i < N; i++) { const a = Math.random()*Math.PI*2, r = 0.4 + Math.random()*0.5;
pos[i*3] = Math.cos(a)*r; pos[i*3+1] = (Math.random()-0.3)*0.9; pos[i*3+2] = Math.sin(a)*r; }
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({ map: GLOW_TEX, color: new THREE.Color(hex), size: 0.32,
transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false });
return new THREE.Points(geo, mat);
}
// ════════════════════ MODEL LOADING ════════════════════
const MODELS_BASE = 'Models/GLB%20format/';
const NEEDED = ['tree','tree-pine','rocks','stones','flowers','flowers-tall','mushrooms','grass','plant',
'coin-gold','coin-silver','star','jewel','chest','flag','sign','fence-straight','crate','barrel','character-oobi'];
const SCALE = { 'tree':2.4,'tree-pine':2.4,'rocks':1.8,'stones':1.8,'flowers':1.6,'flowers-tall':1.6,
'mushrooms':1.6,'grass':1.6,'plant':1.8,'coin-gold':1.4,'coin-silver':1.4,'star':1.5,'jewel':1.4,
'chest':2.0,'flag':2.4,'sign':1.8,'fence-straight':1.6,'crate':1.6,'barrel':1.6,'character-oobi':1.2 };
const gltfLoader = new THREE.GLTFLoader();
const cache = {};
let loadedCount = 0;
const loadtext = document.getElementById('loadtext');
function loadModel(name) {
return new Promise((resolve, reject) => {
gltfLoader.load(MODELS_BASE + name + '.glb', g => { cache[name] = g.scene;
loadtext.textContent = `Loaded ${++loadedCount} / ${NEEDED.length}`; resolve();
}, undefined, e => { console.error('load fail', name, e); reject(e); });
});
}
function place(name, x, z, { y=0, rotY=null, scale=null, parent=scene } = {}) {
if (!cache[name]) return null;
const o = cache[name].clone(true);
const s = scale ?? SCALE[name] ?? 1;
o.scale.setScalar(s); o.position.set(x, y, z);
o.rotation.y = rotY ?? Math.random()*Math.PI*2;
o.traverse(c => { if (c.isMesh) { c.castShadow = true; c.receiveShadow = true; } });
parent.add(o); return o;
}
// ── EXTRA NATURE PACK (user's glTF/ folder; external .bin + textures) ──
const NATURE_BASE = 'glTF/';
const NATURE = ['BirchTree_1','BirchTree_3','BirchTree_5','MapleTree_1','MapleTree_3','MapleTree_5',
'DeadTree_2','DeadTree_6','Bush','Bush_Flowers','Bush_Large_Flowers','Flower_1_Clump','Flower_4_Clump','Grass_Large'];
const NAT_TREE_SCALE = 1.0, NAT_BUSH_SCALE = 1.0, NAT_GROUND_SCALE = 1.2; // tweak to taste
function loadNature(name) {
return new Promise(res => {
gltfLoader.load(NATURE_BASE + name + '.gltf', g => { cache['nat_' + name] = g.scene; res(); },
undefined, e => { console.warn('nature load fail', name, e); res(); });
});
}
// ════════════════════ HERO (rigged + animated) ════════════════════
const HERO_URL = 'Models/hero/RobotExpressive.glb';
const HERO_SCALE = 0.5;
let mixer = null, heroReady = false, currentAction = null;
const heroActions = {};
function loadHero() {
return new Promise(resolve => {
gltfLoader.load(HERO_URL, g => {
const m = g.scene; m.scale.setScalar(HERO_SCALE); m.rotation.y = Math.PI;
m.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
mixer = new THREE.AnimationMixer(m);
g.animations.forEach(cl => heroActions[cl.name] = mixer.clipAction(cl));
currentAction = heroActions['Idle'] || mixer.clipAction(g.animations[0]);
currentAction && currentAction.play();
playerGroup.add(m); playerGroup.userData.model = m; heroReady = true; resolve();
}, undefined, () => { console.warn('hero failed; using fallback'); resolve(); });
});
}
function setHeroAction(name) {
const next = heroActions[name]; if (!next || next === currentAction) return;
next.reset().fadeIn(0.2).play(); currentAction && currentAction.fadeOut(0.2); currentAction = next;
}
// one-shot Jump clip (doesn't loop)
function playJump() {
const j = heroActions['Jump']; if (!j) return;
j.reset(); j.setLoop(THREE.LoopOnce, 1); j.clampWhenFinished = true;
j.fadeIn(0.08).play(); currentAction && currentAction.fadeOut(0.08); currentAction = j;
}
// ── JUMP + GRAVITY (platformer: leap forward + up, land on low-poly trees) ──
let vy = 0, grounded = true, jumpMX = 0, jumpMZ = 0;
const JUMP_VEL = 14, GRAVITY = 22, JUMP_FWD = 7;
function tryJump() {
if (!grounded) return;
if (!playerGroup.userData.model) return;
vy = JUMP_VEL; grounded = false;
jumpMX = -Math.sin(playerAngle) * JUMP_FWD; // carry forward momentum through the arc
jumpMZ = -Math.cos(playerAngle) * JUMP_FWD;
if (mixer) playJump();
playEfx('wow'); // "Wow!" on every Space / jump press
setTimeout(() => playEfx('jump'), 150); // then the jump effect sound
}
// ════════════════════ STATE ════════════════════
const playerGroup = new THREE.Group(); scene.add(playerGroup);
const obstacles = []; // { x, z, r } solid things
const collectibles = []; // active pickups
const bursts = []; // particle bursts
let score = 0;
// level / phase learning state
const NUM_WORDS = ['one','two','three','four','five','six','seven','eight','nine','ten'];
const NUMBER_HEX = '#ffb300', LETTER_HEX = '#36c6ff';
let level = 1, phase = 'find', target = '', word = '', spellIdx = 0, lastReject = 0;
const scoreEl = document.getElementById('score');
const infoEl = document.getElementById('info');
const taskTitle = () => document.getElementById('task-title');
const taskBody = () => document.getElementById('task-body');
const PLAYER_R = 0.5;
const shuffle = a => { for (let i = a.length-1; i>0; i--){ const j = (Math.random()*(i+1))|0; [a[i],a[j]]=[a[j],a[i]]; } return a; };
// top = height of the object's top surface (you can stand on it); 99 = unclimbable wall
function registerObstacle(x, z, r, top = 99) { obstacles.push({ x, z, r, top }); }
// is (x,z) blocked for a player whose feet are at feetY? (only things taller than the feet block)
function blockedAt(x, z, feetY) {
for (const o of obstacles) { const dx = x-o.x, dz = z-o.z, rr = o.r + PLAYER_R;
if (dx*dx + dz*dz < rr*rr && o.top > feetY + 0.3) return true; }
return false;
}
// highest surface the player can land on at (x,z) given they're descending from curY
function groundHeight(x, z, curY) {
let g = 0;
for (const o of obstacles) { const dx = x-o.x, dz = z-o.z;
if (dx*dx + dz*dz < o.r*o.r && o.top <= curY + 0.4 && o.top > g) g = o.top; }
return g;
}
function blocked(x, z) {
for (const o of obstacles) { const dx = x-o.x, dz = z-o.z, rr = o.r + PLAYER_R;
if (dx*dx + dz*dz < rr*rr) return true; } return false;
}
// ════════════════════ AUDIO (edge-tts clips, boosted) ════════════════════
let actx = null, masterGain = null, comp = null, sfxGain = null, audioReady = false, muted = false;
const buffers = {}; const voiceQueue = []; let voicePlaying = false;
// sound-effect & music files the user added
const EFX = {
wow:'wow.mp3', playerwow:'player wow.mp3', collect:'Coin collect.mp3', collect2:'collecting effect.mp3',
pickup:'item-pickup.mp3', jump:'jump effect.mp3', walk:'walking effect.mp3',
complete:'task complete.mp3', bonus:'universfield-game-bonus-144751.mp3',
cheer:'cheer_crowd.ogg', applause:'applause.ogg', teamcheer:'team_cheer.ogg',
ohno:'oh no.mp3', levelup:'level_up.ogg'
};
const efxBuf = {};
const MUSIC = ['audiocoffee-game-116679','denis-pavlov-music-marimba-tropical-african-travel-game-197517',
'good_b_music-fairytail-quest-for-game-394561','soundore-kid-366901'];
let musicEl = null; const MUSIC_VOL = 0.22; // background music at a reduced level (~22%)
function initAudio() {
actx = new (window.AudioContext || window.webkitAudioContext)();
comp = actx.createDynamicsCompressor(); // tame peaks while loud
masterGain = actx.createGain(); masterGain.gain.value = muted ? 0 : 1.8; // extra ~200% boost
comp.connect(masterGain); masterGain.connect(actx.destination);
sfxGain = actx.createGain(); sfxGain.gain.value = muted ? 0 : 0.85; sfxGain.connect(actx.destination);
audioReady = true;
}
// ── SYNTHESIZED SOUND EFFECTS (no files; random variations) ──
function blip(freq, dur, type, t0, peak) {
const o = actx.createOscillator(), g = actx.createGain();
o.type = type; o.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(peak, t0 + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(sfxGain); o.start(t0); o.stop(t0 + dur + 0.02);
return o;
}
const PENTA = [523.25, 587.33, 659.25, 783.99, 880.0, 1046.5]; // happy C-major pentatonic
function sfxCollect() { // bright random 2-note sparkle
if (!audioReady) return; const t0 = actx.currentTime;
const n = PENTA[(Math.random()*PENTA.length)|0];
blip(n, 0.13, 'triangle', t0, 0.5);
blip(n * 1.5, 0.13, 'triangle', t0 + 0.07, 0.4);
}
function sfxJump() { // upward "boing"
if (!audioReady) return; const t0 = actx.currentTime;
const o = actx.createOscillator(), g = actx.createGain();
o.type = 'sine'; const f0 = 280 + Math.random()*120;
o.frequency.setValueAtTime(f0, t0);
o.frequency.exponentialRampToValueAtTime(f0 * 3, t0 + 0.18);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.4, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.22);
o.connect(g); g.connect(sfxGain); o.start(t0); o.stop(t0 + 0.24);
}
function sfxWrong() { // gentle low "uh-oh" buzz
if (!audioReady) return; const t0 = actx.currentTime;
const o = actx.createOscillator(), g = actx.createGain();
o.type = 'square'; o.frequency.setValueAtTime(240, t0);
o.frequency.exponentialRampToValueAtTime(120, t0 + 0.26);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.22, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.3);
o.connect(g); g.connect(sfxGain); o.start(t0); o.stop(t0 + 0.32);
}
// ── REAL SOUND-EFFECT FILES (efx/) ──
function loadEfx(key) {
if (efxBuf[key]) return Promise.resolve(efxBuf[key]);
return fetch('efx/' + encodeURIComponent(EFX[key])).then(r => r.arrayBuffer())
.then(a => actx.decodeAudioData(a)).then(b => (efxBuf[key] = b));
}
function playEfx(key, vol = 1) {
if (!audioReady || muted) return;
loadEfx(key).then(buf => {
const s = actx.createBufferSource(); s.buffer = buf;
const g = actx.createGain(); g.gain.value = vol;
s.connect(g); g.connect(sfxGain); s.start();
}).catch(() => {});
}
// ── LOOPING WALK SOUND (starts on move, stops when still/airborne) ──
let walkSrc = null, walking = false;
function startWalk() {
if (walking || !audioReady || muted) return;
walking = true;
loadEfx('walk').then(buf => {
if (!walking) return;
walkSrc = actx.createBufferSource(); walkSrc.buffer = buf; walkSrc.loop = true;
const g = actx.createGain(); g.gain.value = 0.7; walkSrc.connect(g); g.connect(sfxGain); walkSrc.start();
}).catch(() => {});
}
function stopWalk() {
walking = false;
if (walkSrc) { try { walkSrc.stop(); } catch (e) {} walkSrc = null; }
}
// ── BACKGROUND MUSIC (sounds/) — reduced level, random, looping ──
function startMusic() {
if (musicEl) return;
musicEl = new Audio();
musicEl.volume = muted ? 0 : MUSIC_VOL;
const playRandom = () => { const tr = MUSIC[(Math.random()*MUSIC.length)|0];
musicEl.src = 'sounds/' + tr + '.mp3'; musicEl.play().catch(() => {}); };
musicEl.addEventListener('ended', playRandom); // pick a new random track each time (repeat mode)
playRandom();
}
// ── MUTE / SOUND TOGGLE ──
function setMuted(m) {
muted = m;
if (masterGain) masterGain.gain.value = m ? 0 : 1.8;
if (sfxGain) sfxGain.gain.value = m ? 0 : 0.5;
if (musicEl) musicEl.volume = m ? 0 : MUSIC_VOL;
if (m) stopWalk();
const b = document.getElementById('soundbtn'); if (b) b.textContent = m ? '🔇' : '🔊';
}
function loadClip(name) {
if (buffers[name]) return Promise.resolve(buffers[name]);
return fetch('audio/' + name + '.mp3').then(r => r.arrayBuffer())
.then(a => actx.decodeAudioData(a)).then(b => (buffers[name] = b));
}
function preload(names) { names.forEach(n => loadClip(n).catch(()=>{})); }
function say(name, interrupt) {
if (!audioReady) return;
if (interrupt) voiceQueue.length = 0;
if (voiceQueue.length > 2) voiceQueue.shift(); // stay responsive
voiceQueue.push(name);
if (!voicePlaying) playNext();
}
function playNext() {
if (!voiceQueue.length) { voicePlaying = false; return; }
voicePlaying = true;
const name = voiceQueue.shift();
loadClip(name).then(buf => {
const s = actx.createBufferSource(); s.buffer = buf; s.connect(comp);
s.onended = playNext; s.start();
}).catch(playNext);
}
const cheer = () => say('cheer_' + Math.floor(Math.random()*8));
// ════════════════════ STATIC WORLD ════════════════════
function buildWorld() {
if (!heroReady) { const f = place('character-oobi', 0, 0, { parent: playerGroup, rotY: Math.PI });
playerGroup.userData.model = f; }
// LOW-POLY FOREST — well-spaced trees you can JUMP ONTO, each with grass tufts at the base
const trees = []; let tries = 0;
while (trees.length < 30 && tries < 700) {
tries++;
const a = Math.random()*Math.PI*2, r = 11 + Math.random()*22;
const x = Math.cos(a)*r, z = Math.sin(a)*r;
if (x*x + z*z < 100) continue; // big clear play area in the middle
if (trees.some(p => (p[0]-x)**2 + (p[1]-z)**2 < 36)) continue; // 6+ units apart
trees.push([x, z]);
}
trees.forEach(([x, z], i) => {
const k = i%3===0 ? 'tree-pine' : 'tree';
place(k, x, z, { scale: SCALE[k]*(0.9+Math.random()*0.4) });
registerObstacle(x, z, 1.0, 2.5); // landable top at y≈2.5
const ring = 4 + (Math.random()*3|0); // grass arranged around the trunk
for (let j = 0; j < ring; j++) { const ang = (j/ring)*Math.PI*2 + Math.random()*0.5, rr = 1.3 + Math.random()*1.1;
place(Math.random()<0.7 ? 'grass' : 'flowers', x+Math.cos(ang)*rr, z+Math.sin(ang)*rr, { scale: 1.3 }); }
});
// rocks — small, landable (collision), scattered wider
for (let i = 0; i < 10; i++) { const a = Math.random()*Math.PI*2, r = 10 + Math.random()*22;
const x = Math.cos(a)*r, z = Math.sin(a)*r;
place(i%2 ? 'stones' : 'rocks', x, z); registerObstacle(x, z, 0.9, 0.7); }
// free ground detail (no collision)
const detail = ['flowers','flowers-tall','mushrooms','grass','plant'];
for (let i = 0; i < 70; i++) { const a = Math.random()*Math.PI*2, r = 5 + Math.random()*28;
place(detail[(Math.random()*detail.length)|0], Math.cos(a)*r, Math.sin(a)*r); }
// boxes & landmarks (landable platforms)
place('crate', -5, 3); registerObstacle(-5, 3, 0.7, 1.2);
place('crate', -5, 4.1); registerObstacle(-5, 4.1, 0.7, 1.2);
place('barrel', 5, 3); registerObstacle(5, 3, 0.6, 1.3);
place('sign', 0, 7, { rotY: 0 }); registerObstacle(0, 7, 0.6, 1.4);
place('chest', 9, -9, { rotY: -0.6 }); registerObstacle(9, -9, 0.8, 1.0);
place('flag', -9, -8, { rotY: 0.5 }); registerObstacle(-9, -8, 0.5, 99);
// decorative fence ring marking the edge of the play clearing (no collision — purely scenery)
const FENCE_R = 30, FENCE_N = 28;
for (let i = 0; i < FENCE_N; i++) { const ang = (i / FENCE_N) * Math.PI*2;
place('fence-straight', Math.cos(ang)*FENCE_R, Math.sin(ang)*FENCE_R, { rotY: ang + Math.PI/2 }); }
// HIGH-POLY NATURE — kept FAR away as a background ring (so the camera never clips them)
const natTrees = ['nat_BirchTree_1','nat_BirchTree_3','nat_BirchTree_5','nat_MapleTree_1','nat_MapleTree_3','nat_MapleTree_5','nat_DeadTree_2','nat_DeadTree_6'];
const natBush = ['nat_Bush','nat_Bush_Flowers','nat_Bush_Large_Flowers'];
const natGround = ['nat_Flower_1_Clump','nat_Flower_4_Clump','nat_Grass_Large'];
let nt = 0, ntries = 0;
while (nt < 26 && ntries < 500) { ntries++;
const a = Math.random()*Math.PI*2, r = 42 + Math.random()*26; // distant background only
const x = Math.cos(a)*r, z = Math.sin(a)*r;
if (place(natTrees[(Math.random()*natTrees.length)|0], x, z, { scale: NAT_TREE_SCALE })) {
registerObstacle(x, z, 1.2, 99); nt++; }
}
for (let i = 0; i < 12; i++) { const a = Math.random()*Math.PI*2, r = 34 + Math.random()*30;
const x = Math.cos(a)*r, z = Math.sin(a)*r;
place(natBush[(Math.random()*natBush.length)|0], x, z, { scale: NAT_BUSH_SCALE }); }
for (let i = 0; i < 40; i++) { const a = Math.random()*Math.PI*2, r = 30 + Math.random()*32;
place(natGround[(Math.random()*natGround.length)|0], Math.cos(a)*r, Math.sin(a)*r, { scale: NAT_GROUND_SCALE }); }
document.getElementById('loader').style.display = 'none';
document.getElementById('start').style.display = 'flex';
clock.start(); animate();
}
// ════════════════════ COLLECTIBLES ════════════════════
function clearCollectibles() {
collectibles.forEach(c => scene.remove(c.group));
collectibles.length = 0;
}
// spread spot among the trees (away from obstacles, the centre & other coins)
function freeSpot() {
for (let t = 0; t < 60; t++) {
const a = Math.random()*Math.PI*2, r = 7 + Math.random()*21; // inside the play area
const x = Math.cos(a)*r, z = Math.sin(a)*r;
if (blocked(x, z)) continue;
if (collectibles.some(c => (c.x-x)**2 + (c.z-z)**2 < 16)) continue;
if (x*x + z*z < 20) continue; // not on the player spawn
return [x, z];
}
return [(Math.random()*40 - 20), (Math.random()*40 - 20)];
}
function spawnCollectible(model, x, z, { label = '', hex = NUMBER_HEX, kind = 'reward', char = '', value = 1 } = {}) {
const group = new THREE.Group(); group.position.set(x, 0, z);
const baseY = 1.0;
const spinner = new THREE.Group(); spinner.position.y = baseY;
const m = cache[model].clone(true); m.scale.setScalar(SCALE[model] || 1.4);
m.traverse(o => { if (o.isMesh) o.castShadow = true; });
spinner.add(m); group.add(spinner);
const glow = glowSprite(hex); glow.position.y = 0.12; group.add(glow);
const sp = sparkleCloud(hex); sp.position.y = baseY; group.add(sp);
let labelSprite = null;
if (label) { labelSprite = makeLabel(label, hex); labelSprite.position.y = baseY + 1.5; group.add(labelSprite); }
scene.add(group);
collectibles.push({ group, spinner, glow, sparkles: sp, label: labelSprite, baseY, kind, char, value, x, z, collected: false });
}
function updateScore() { scoreEl.textContent = '★ ' + score; }
// ════════════════════ LEVEL / LEARNING FLOW ════════════════════
// Each level = one number. Phase 1: find the right digit. Phase 2: spell its word.
function startLevel(n) {
level = ((n - 1) % 10) + 1;
word = NUM_WORDS[level - 1];
document.getElementById('levelbadge').textContent = 'Level ' + level;
playEfx('levelup', 1.1);
startFind();
}
// ── PHASE 1: find the target number among distractor numbers ──
function startFind() {
phase = 'find'; clearCollectibles();
target = String(level);
const pool = []; for (let i = 1; i <= 10; i++) if (String(i) !== target) pool.push(String(i));
shuffle(pool);
const digits = shuffle([target, ...pool.slice(0, 5)]); // 1 correct + 5 wrong
digits.forEach(d => { const [x, z] = freeSpot();
spawnCollectible('coin-gold', x, z, { label: d, hex: NUMBER_HEX, kind: 'number', char: d, value: 1 }); });
taskTitle().textContent = 'FIND THE NUMBER';
taskBody().innerHTML = `<span class="bignum">${target}</span>`;
infoEl.textContent = 'Find the number ' + target + '!';
preload(['find_' + level, 'num_' + level, 'try_again']);
say('find_' + level, true);
}
// ── PHASE 2: spell the word, letters in order ──
function startSpell() {
phase = 'spell'; clearCollectibles(); spellIdx = 0;
const need = word.split('');
const extra = []; const AL = 'abcdefghijklmnopqrstuvwxyz';
while (extra.length < 3) { const r = AL[(Math.random()*26)|0];
if (!word.includes(r) && !extra.includes(r)) extra.push(r); }
shuffle([...need, ...extra]).forEach(ch => { const [x, z] = freeSpot();
spawnCollectible('coin-silver', x, z, { label: ch.toUpperCase(), hex: LETTER_HEX, kind: 'letter', char: ch, value: 1 }); });
showSpellTask();
infoEl.textContent = 'Spell ' + word.toUpperCase() + '!';
preload([...word.split('').map(c => 'name_' + c), 'spell_' + level, 'level_complete', 'try_again']);
say('spell_' + level, true);
}
function showSpellTask() {
taskTitle().textContent = 'SPELL THE WORD';
taskBody().innerHTML = `<span class="bignum">${level}</span>` +
word.split('').map((ch, i) => `<span class="slot" id="slot${i}">${ch.toUpperCase()}</span>`).join('');
updateSpellSlots();
}
function updateSpellSlots() {
word.split('').forEach((ch, i) => { const el = document.getElementById('slot' + i); if (!el) return;
el.classList.toggle('done', i < spellIdx);
el.classList.toggle('next', i === spellIdx); });
}
// ── pickup handler: accept the right one, reject the rest ──
function onTouch(c) {
if (c.collected) return;
if (phase === 'find') {
if (c.char === target) { accept(c); say('num_' + level); cheer(); setTimeout(startSpell, 1400); }
else reject(c);
} else if (phase === 'spell') {
if (c.char === word[spellIdx]) {
accept(c); say('name_' + c.char); spellIdx++; updateSpellSlots();
if (spellIdx >= word.length) setTimeout(celebrateLevel, 800);
} else reject(c);
} else { // reward phase — free coins
accept(c); if (Math.random() < 0.5) cheer();
}
}
function accept(c) {
c.collected = true; c.group.visible = false;
score += c.value; updateScore();
burst(c.group.position, c.glow.material.color.getHex());
if (c.value >= 5) playEfx('bonus', 1.4); // big jingle for stars/jewels
else if (c.kind === 'reward') playEfx('pickup', 1.6); // free-coin pickup
else playEfx(Math.random() < 0.5 ? 'collect' : 'collect2', 1.8); // louder coin-collect sound on success
sfxCollect(); // + a bright sparkle note
}
function reject(c) {
const now = performance.now();
if (now - lastReject > 850) { lastReject = now; playEfx('ohno', 1.5); sfxWrong(); say('try_again'); }
// nudge the player off the wrong coin so they can try another
const dx = playerGroup.position.x - c.x, dz = playerGroup.position.z - c.z, d = Math.hypot(dx, dz) || 1;
playerGroup.position.x += dx/d * 0.7; playerGroup.position.z += dz/d * 0.7;
}
// ── level finished: celebrate + reward coins, then next level ──
function celebrateLevel() {
phase = 'reward'; clearCollectibles(); confetti();
playEfx('playerwow', 1.4); // player's "wow!" cheer on finishing the level
playEfx('complete', 1.4); // task-complete fanfare at a good level
setTimeout(() => playEfx('cheer', 1.1), 300); // + crowd cheer
say('level_complete', true);
taskTitle().textContent = 'GREAT JOB! ⭐';
taskBody().innerHTML = `<span class="bignum">${level}</span><span class="word">${word.toUpperCase()}</span>`;
infoEl.textContent = '🎉 Level ' + level + ' done! Grab the coins!';
for (let i = 0; i < 6; i++) { const [x, z] = freeSpot();
const special = i === 0 ? 'star' : (i === 1 ? 'jewel' : 'coin-gold'); // a star + a shiny jewel, rest coins
const big = i < 2;
spawnCollectible(special, x, z,
{ hex: i === 0 ? '#ffe34d' : (i === 1 ? '#7be3ff' : NUMBER_HEX), kind: 'reward', value: big ? 5 : 1 }); }
setTimeout(() => startLevel(level + 1), 6500);
}
// ════════════════════ PARTICLE BURSTS ════════════════════
function burst(pos, hex, count = 26, up = 2.0) {
const geo = new THREE.BufferGeometry(); const p = new Float32Array(count*3); const vel = [];
for (let i = 0; i < count; i++) { p[i*3] = pos.x; p[i*3+1] = pos.y + 1; p[i*3+2] = pos.z;
const a = Math.random()*Math.PI*2, e = Math.random()*Math.PI, s = 2 + Math.random()*2.5;
vel.push(new THREE.Vector3(Math.sin(e)*Math.cos(a)*s, Math.abs(Math.cos(e))*s + up, Math.sin(e)*Math.sin(a)*s)); }
geo.setAttribute('position', new THREE.BufferAttribute(p, 3));
const mat = new THREE.PointsMaterial({ map: GLOW_TEX, color: new THREE.Color(hex), size: 0.45,
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
const pts = new THREE.Points(geo, mat); scene.add(pts);
bursts.push({ pts, vel, life: 0, ttl: 0.9 });
}
function confetti() {
const colors = ['#ff5d8f','#ffc400','#36c6ff','#7ed957','#9b6bff','#ff8a3d'];
const at = playerGroup.position.clone();
colors.forEach((c, i) => setTimeout(() => burst(at, c, 40, 4.5), i*60));
}
// ── GREETING SPECTACLE: lightning flashes + swirling magic particles ──
let flashLight = null;
function lightning(times) {
if (!flashLight) { flashLight = new THREE.PointLight(0xeaf2ff, 0, 300); scene.add(flashLight); }
let n = 0;
const strike = () => {
if (n >= times) { flashLight.intensity = 0; renderer.toneMappingExposure = 0.7; return; }
flashLight.position.set(playerGroup.position.x + (Math.random()*30-15), 42, playerGroup.position.z + (Math.random()*30-15));
flashLight.intensity = 13; renderer.toneMappingExposure = 1.7; // bright flash
setTimeout(() => { flashLight.intensity = 0; renderer.toneMappingExposure = 0.7; }, 70);
setTimeout(() => { flashLight.intensity = 9; renderer.toneMappingExposure = 1.35;
setTimeout(() => { flashLight.intensity = 0; renderer.toneMappingExposure = 0.7; }, 55); }, 150); // flicker
n++; setTimeout(strike, 360 + Math.random()*340);
};
strike();
}
function greetFX() {
const colors = ['#ffe34d','#36c6ff','#ff5d8f','#7ed957','#b15bff'];
colors.forEach((c, i) => setTimeout(() => burst(playerGroup.position.clone(), c, 60, 6), i*180));
lightning(5);
// big cheering welcome for the kids
playEfx('cheer', 1.3);
setTimeout(() => playEfx('applause', 1.2), 900);
setTimeout(() => playEfx('teamcheer', 1.0), 2200);
}
// ════════════════════ INPUT ════════════════════
const keys = { ArrowUp:false, ArrowDown:false, ArrowLeft:false, ArrowRight:false };
const keyEls = {};
document.querySelectorAll('.key').forEach(el => keyEls[el.dataset.k] = el);
function setKey(k, on) { if (!(k in keys)) return; keys[k] = on; keyEls[k] && keyEls[k].classList.toggle('active', on); }
addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); tryJump(); return; }
if (e.key in keys) { e.preventDefault(); setKey(e.key, true); }
});
addEventListener('keyup', e => { if (e.key in keys) setKey(e.key, false); });
// touch / mouse on-screen pad
document.querySelectorAll('.key').forEach(el => {
const k = el.dataset.k;
const on = e => { e.preventDefault(); setKey(k, true); };
const off = e => { e.preventDefault(); setKey(k, false); };
el.addEventListener('pointerdown', on); el.addEventListener('pointerup', off);
el.addEventListener('pointerleave', off); el.addEventListener('pointercancel', off);
});
// jump button (touch)
document.getElementById('jump-btn').addEventListener('pointerdown', e => { e.preventDefault(); tryJump(); });
// sound on/off button
document.getElementById('soundbtn').addEventListener('click', e => { e.preventDefault(); setMuted(!muted); });
// ════════════════════ LOOP ════════════════════
const WALK_SPEED = 9, TURN_RATE = 3.4; // units/sec, rad/sec (faster than before)
let playerAngle = 0, bobTime = 0, stepTimer = 0;
const clock = new THREE.Clock(false);
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05);
const t = clock.getElapsedTime();
let moving = false, dirX = 0, dirZ = 0;
if (keys.ArrowLeft) { playerAngle += TURN_RATE*delta; moving = true; }
if (keys.ArrowRight) { playerAngle -= TURN_RATE*delta; moving = true; }
if (keys.ArrowUp) { dirX -= Math.sin(playerAngle); dirZ -= Math.cos(playerAngle); moving = true; }
if (keys.ArrowDown) { dirX += Math.sin(playerAngle); dirZ += Math.cos(playerAngle); moving = true; }
// horizontal velocity = walk input + any forward jump momentum
const feetY = playerGroup.position.y;
const dispX = (dirX*WALK_SPEED + jumpMX) * delta;
const dispZ = (dirZ*WALK_SPEED + jumpMZ) * delta;
const px = playerGroup.position.x, pz = playerGroup.position.z;
if (dispX && !blockedAt(px + dispX, pz, feetY)) playerGroup.position.x += dispX;
if (dispZ && !blockedAt(playerGroup.position.x, pz + dispZ, feetY)) playerGroup.position.z += dispZ;
playerGroup.position.x = Math.max(-68, Math.min(68, playerGroup.position.x));
playerGroup.position.z = Math.max(-68, Math.min(68, playerGroup.position.z));
playerGroup.rotation.y = playerAngle;
// gravity + landing (on the ground OR on top of trees/boxes)
vy -= GRAVITY*delta;
let ny = playerGroup.position.y + vy*delta;
const gY = groundHeight(playerGroup.position.x, playerGroup.position.z, playerGroup.position.y);
if (ny <= gY) { ny = gY; vy = 0; grounded = true; jumpMX = 0; jumpMZ = 0; }
else grounded = false;
playerGroup.position.y = ny;
// hero animation (Jump clip plays while airborne)
if (mixer) { if (grounded) setHeroAction(moving ? 'Walking' : 'Idle'); mixer.update(delta); }
else { bobTime += (moving && grounded) ? delta*9 : 0; const m = playerGroup.userData.model;
if (m) m.position.y = (moving && grounded) ? Math.abs(Math.sin(bobTime))*0.22 : 0; }
// walking sound (loops while moving on the ground, from efx/walking effect.mp3)
if (moving && grounded) startWalk(); else stopWalk();
// collectibles: spin, float, twinkle, pickup
for (const c of collectibles) {
if (c.collected) continue;
c.spinner.rotation.y = t*2;
const fy = Math.sin(t*3 + c.x)*0.16;
c.spinner.position.y = c.baseY + fy;
if (c.label) c.label.position.y = c.baseY + 1.5 + fy;
c.glow.material.opacity = 0.45 + Math.sin(t*4 + c.x)*0.25;
c.sparkles.rotation.y = t*1.6;
c.sparkles.material.opacity = 0.6 + Math.sin(t*6 + c.z)*0.4;
const dx = playerGroup.position.x - c.x, dz = playerGroup.position.z - c.z;
if (dx*dx + dz*dz < 1.5*1.5) onTouch(c);
}
// particle bursts
for (let i = bursts.length - 1; i >= 0; i--) {
const b = bursts[i]; b.life += delta; const p = b.pts.geometry.attributes.position;
for (let j = 0; j < b.vel.length; j++) { b.vel[j].y -= 6*delta;
p.setXYZ(j, p.getX(j)+b.vel[j].x*delta, p.getY(j)+b.vel[j].y*delta, p.getZ(j)+b.vel[j].z*delta); }
p.needsUpdate = true;
b.pts.material.opacity = Math.max(0, 1 - b.life/b.ttl);
if (b.life >= b.ttl) { scene.remove(b.pts); b.pts.geometry.dispose(); b.pts.material.dispose(); bursts.splice(i, 1); }
}
// camera (behind player → Up = forward)
const camT = new THREE.Vector3(
playerGroup.position.x + Math.sin(playerAngle)*CAM_OFFSET.z,
playerGroup.position.y + CAM_OFFSET.y,
playerGroup.position.z + Math.cos(playerAngle)*CAM_OFFSET.z);
camera.position.lerp(camT, 0.1);
camera.lookAt(playerGroup.position.x, playerGroup.position.y + 1.2, playerGroup.position.z);
document.getElementById('coords').textContent =
`x:${playerGroup.position.x.toFixed(0)} z:${playerGroup.position.z.toFixed(0)}`;
renderer.render(scene, camera);
}
addEventListener('resize', () => {
camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// ════════════════════ START (tap → audio + greeting) ════════════════════
document.getElementById('start-btn').addEventListener('click', () => {
document.getElementById('start').style.display = 'none';
initAudio();
if (actx.state === 'suspended') actx.resume();
['wow','playerwow','collect','collect2','pickup','jump','walk','complete','bonus','cheer','applause','ohno','levelup']
.forEach(k => loadEfx(k).catch(()=>{}));
startMusic(); // background music (reduced, random, looping)
preload(['greet','instr_move','find_1','num_1','level_complete','try_again',
'cheer_0','cheer_1','cheer_2','cheer_3','cheer_4','cheer_5','cheer_6','cheer_7']);
say('greet'); say('instr_move');
greetFX(); // ⚡ lightning + magic particle welcome
taskTitle().textContent = 'Get ready!';
infoEl.textContent = 'Use the arrows to explore the forest!';
setTimeout(() => startLevel(1), 5200); // after greeting finishes
});
// ════════════════════ BOOT ════════════════════
Promise.all([...NEEDED.map(loadModel), loadHero(), ...NATURE.map(loadNature)])
.then(buildWorld)
.catch(err => {
document.querySelector('#loader h1').textContent = '⚠ Could not load models';
loadtext.textContent = 'Serve over http:// and keep the Models folder beside index.html. See console.';
console.error(err);
});
</script>
</body>
</html>