| <!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; } |
| |
| |
| #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; } |
| |
| |
| #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; } |
| |
| |
| #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 & Play!</h1> |
| <p>Numbers, letters & 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> |
| |
| const scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0xcfe8f5); |
| 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)); |
| |
| |
| 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) }, |
| uMid: { value: new THREE.Color(0x4fb0ff) }, |
| uBot: { value: new THREE.Color(0xeaf8ff) }, |
| uSun: { value: sunV.clone() }, |
| uSunCol: { value: new THREE.Color(0xfff0a8) } |
| }, |
| 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); |
| scene.fog.color.setHex(0xeaf8ff); |
| scene.background = new THREE.Color(0xbfe6ff); |
| |
| |
| 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); |
| |
| |
| 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); |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| 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(); }); |
| }); |
| } |
| |
| |
| 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; |
| } |
| |
| 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; |
| } |
| |
| |
| 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; |
| jumpMZ = -Math.cos(playerAngle) * JUMP_FWD; |
| if (mixer) playJump(); |
| playEfx('wow'); |
| setTimeout(() => playEfx('jump'), 150); |
| } |
| |
| |
| const playerGroup = new THREE.Group(); scene.add(playerGroup); |
| const obstacles = []; |
| const collectibles = []; |
| const bursts = []; |
| let score = 0; |
| |
| 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; }; |
| |
| |
| function registerObstacle(x, z, r, top = 99) { obstacles.push({ x, z, r, top }); } |
| |
| 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; |
| } |
| |
| 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; |
| } |
| |
| |
| let actx = null, masterGain = null, comp = null, sfxGain = null, audioReady = false, muted = false; |
| const buffers = {}; const voiceQueue = []; let voicePlaying = false; |
| |
| 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; |
| function initAudio() { |
| actx = new (window.AudioContext || window.webkitAudioContext)(); |
| comp = actx.createDynamicsCompressor(); |
| masterGain = actx.createGain(); masterGain.gain.value = muted ? 0 : 1.8; |
| comp.connect(masterGain); masterGain.connect(actx.destination); |
| sfxGain = actx.createGain(); sfxGain.gain.value = muted ? 0 : 0.85; sfxGain.connect(actx.destination); |
| audioReady = true; |
| } |
| |
| |
| 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]; |
| function sfxCollect() { |
| 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() { |
| 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() { |
| 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); |
| } |
| |
| 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(() => {}); |
| } |
| |
| |
| 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; } |
| } |
| |
| |
| 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); |
| playRandom(); |
| } |
| |
| |
| 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(); |
| 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)); |
| |
| |
| function buildWorld() { |
| if (!heroReady) { const f = place('character-oobi', 0, 0, { parent: playerGroup, rotY: Math.PI }); |
| playerGroup.userData.model = f; } |
| |
| |
| 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; |
| if (trees.some(p => (p[0]-x)**2 + (p[1]-z)**2 < 36)) continue; |
| 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); |
| const ring = 4 + (Math.random()*3|0); |
| 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 }); } |
| }); |
| |
| |
| 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); } |
| |
| |
| 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); } |
| |
| |
| 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); |
| |
| |
| 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 }); } |
| |
| |
| 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; |
| 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(); |
| } |
| |
| |
| function clearCollectibles() { |
| collectibles.forEach(c => scene.remove(c.group)); |
| collectibles.length = 0; |
| } |
| |
| function freeSpot() { |
| for (let t = 0; t < 60; t++) { |
| const a = Math.random()*Math.PI*2, r = 7 + Math.random()*21; |
| 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; |
| 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; } |
| |
| |
| |
| function startLevel(n) { |
| level = ((n - 1) % 10) + 1; |
| word = NUM_WORDS[level - 1]; |
| document.getElementById('levelbadge').textContent = 'Level ' + level; |
| playEfx('levelup', 1.1); |
| startFind(); |
| } |
| |
| |
| 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)]); |
| 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); |
| } |
| |
| |
| 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); }); |
| } |
| |
| |
| 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 { |
| 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); |
| else if (c.kind === 'reward') playEfx('pickup', 1.6); |
| else playEfx(Math.random() < 0.5 ? 'collect' : 'collect2', 1.8); |
| sfxCollect(); |
| } |
| function reject(c) { |
| const now = performance.now(); |
| if (now - lastReject > 850) { lastReject = now; playEfx('ohno', 1.5); sfxWrong(); say('try_again'); } |
| |
| 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; |
| } |
| |
| |
| function celebrateLevel() { |
| phase = 'reward'; clearCollectibles(); confetti(); |
| playEfx('playerwow', 1.4); |
| playEfx('complete', 1.4); |
| setTimeout(() => playEfx('cheer', 1.1), 300); |
| 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'); |
| 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); |
| } |
| |
| |
| 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)); |
| } |
| |
| |
| 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; |
| 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); |
| 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); |
| |
| playEfx('cheer', 1.3); |
| setTimeout(() => playEfx('applause', 1.2), 900); |
| setTimeout(() => playEfx('teamcheer', 1.0), 2200); |
| } |
| |
| |
| 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); }); |
| |
| 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); |
| }); |
| |
| document.getElementById('jump-btn').addEventListener('pointerdown', e => { e.preventDefault(); tryJump(); }); |
| |
| document.getElementById('soundbtn').addEventListener('click', e => { e.preventDefault(); setMuted(!muted); }); |
| |
| |
| const WALK_SPEED = 9, TURN_RATE = 3.4; |
| 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; } |
| |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| 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; } |
| |
| |
| if (moving && grounded) startWalk(); else stopWalk(); |
| |
| |
| 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); |
| } |
| |
| |
| 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); } |
| } |
| |
| |
| 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); |
| }); |
| |
| |
| 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(); |
| 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(); |
| taskTitle().textContent = 'Get ready!'; |
| infoEl.textContent = 'Use the arrows to explore the forest!'; |
| setTimeout(() => startLevel(1), 5200); |
| }); |
| |
| |
| 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> |
|
|