import * as THREE from 'three'; import './style.css'; import {createPayment,beginPayment,swipePayment,cancelPayment} from './payment.js'; import {movementDirection,canWalk} from './controls.js'; import {chapters,createStory,completeChapter,nextChapter,storyReady,routeOffset,discoveries} from './story.js'; import {createFamily} from './characters.js'; import {products,createState,readyForPicnic,purchase,productByCode,channels,formatHetoman} from './game-state.js'; // One shared path coordinate drives both characters, their gait and the camera. const canvas = document.querySelector('#world'); let renderer; try { renderer = new THREE.WebGLRenderer({canvas, antialias: true}); } catch { document.querySelector('#loading').textContent = 'مرورگر شما گرافیک سه‌بعدی را اجرا نمی‌کند. شتاب‌دهی گرافیکی را فعال کنید.'; throw new Error('WebGL unavailable'); } renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setClearColor('#a4b492'); renderer.toneMapping = THREE.ACESFilmicToneMapping; const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2('#a4b492', .018); const camera = new THREE.PerspectiveCamera(42, 1, .1, 140); scene.add(new THREE.HemisphereLight('#fff3cc', '#375946', 2.8)); const sun = new THREE.DirectionalLight('#ffe5a3', 3.3); sun.position.set(-5, 12, -8); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); Object.assign(sun.shadow.camera, {left: -24, right: 24, top: 24, bottom: -24, far: 60}); scene.add(sun); let story=createStory(),chapterId=null,chapterLine=0,chapterTimer=0; const material = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .85, ...extra}); const green = material('#6d975b'), bark = material('#755a3e'), dark = material('#172c2a'); function mesh(geometry, mat, parent = scene, position = [0, 0, 0]) { const m = new THREE.Mesh(geometry, mat); m.position.set(...position); m.castShadow = true; m.receiveShadow = true; parent.add(m); return m; } function box(w, h, d, mat, parent, pos) { return mesh(new THREE.BoxGeometry(w, h, d), mat, parent, pos) } function ball(r, mat, parent, pos) { return mesh(new THREE.SphereGeometry(r, 12, 10), mat, parent, pos) } const ground = mesh(new THREE.PlaneGeometry(160, 120), green); ground.rotation.x = -Math.PI / 2; // A broad, gently curving ribbon defines the walking route. function pathZ(x) { return Math.sin(x * .17) * 1.5 + routeOffset(story,x) } const points = []; for (let x = -42; x <= 50; x += .5) points.push(new THREE.Vector3(x, .025, pathZ(x))); const curve = new THREE.CatmullRomCurve3(points); const path = mesh(new THREE.TubeGeometry(curve, 180, 1.35, 8, false), material('#cbb889')); path.scale.y = .025; let seed = 42; function random() { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296 } const trees=[]; function tree(x, z, s) { const g = new THREE.Group(); g.position.set(x, 0, z); g.scale.setScalar(s); trees.push(g); scene.add(g); mesh(new THREE.CylinderGeometry(.17, .3, 2.6, 7), bark, g, [0, 1.3, 0]); const foliage = material(['#315e42', '#47764b', '#597f4c', '#749759'][Math.floor(random() * 4)]); for (let i = 0; i < 3; i++) { mesh(new THREE.ConeGeometry(1.55 - i * .25, 2.6, 7), foliage, g, [0, 2.7 + i * .9, 0]); } } for (let i = 0; i < 130; i++) { const x = random() * 100 - 45, z = random() * 65 - 33; if (Math.abs(z - pathZ(x)) > 3.6 && !(Math.abs(x - 9) < 4 && z > -5 && z < 2) && !(x>17 && x<28 && Math.abs(z)<7)) tree(x, z, .7 + random() * 1.2) } for (let i = 0; i < 150; i++) { const x = random() * 65 - 25, z = random() * 32 - 16; if (Math.abs(z - pathZ(x)) < 1.7) continue; const rock = mesh(new THREE.DodecahedronGeometry(.13 + random() * .3), material('#8c9c78'), scene, [x, .12, z]); rock.scale.y = .6; } for (let i = 0; i < 100; i++) { const x = random() * 55 - 20, z = random() * 25 - 12; if (Math.abs(z - pathZ(x)) < 1.5) continue; ball(.065, material(i % 3 ? '#eadba0' : '#e4ae89'), scene, [x, .22, z]); } const characters=createFamily(); const family=characters.group; scene.add(family); // Procedural vending machine, with a canvas sign and visible product rows. const stationX = 9, stationZ = pathZ(9) - 2.65; const machine = new THREE.Group(); machine.position.set(stationX, 0, stationZ); machine.rotation.y = .13; scene.add(machine); box(1.75, 2.9, 1.1, dark, machine, [0, 1.48, 0]); box(1.25, 1.85, .08, material('#a6d5c3', {metalness: .4, roughness: .22}), machine, [-.13, 1.55, .59]); box(1.13, 1.72, .1, material('#223f3b'), machine, [-.13, 1.55, .65]); const bottleMats = ['#e5ae47', '#badd81', '#c8704e'].map(c => material(c)); const steel=material('#aeb8b6',{metalness:.8,roughness:.3}); for(let row=0;row<6;row++){ const shelfY=.86+row*.275;box(1.17,.025,.28,steel,machine,[-.13,shelfY,.73]); for(let col=0;col<10;col++){ const channel=(5-row)*10+col+1,px=-.65+col*.115; if(channel%3){mesh(new THREE.CylinderGeometry(.033,.04,.155,10),bottleMats[channel%3],machine,[px,shelfY+.105,.73]);box(.035,.035,.035,dark,machine,[px,shelfY+.195,.73]);} else box(.068,.14,.07,bottleMats[channel%3],machine,[px,shelfY+.09,.73]); // Each slot has its own dispensing spiral. const spiral=mesh(new THREE.TorusGeometry(.035,.006,4,10),steel,machine,[px,shelfY+.045,.79]); } } const slots=document.createElement('canvas');slots.width=1000;slots.height=900; const slotContext=slots.getContext('2d');slotContext.textAlign='center';slotContext.font='bold 25px sans-serif'; for(let row=0;row<6;row++)for(let col=0;col<10;col++){ const n=row*10+col+1;slotContext.fillStyle=n>=21?'#d1f0b3':'#9ca6a0';slotContext.fillRect(col*100+8,row*150+120,84,30);slotContext.fillStyle='#172c2a';slotContext.fillText(String(n),col*100+50,row*150+144); } const slotLabels=mesh(new THREE.PlaneGeometry(1.16,1.65),new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(slots),transparent:true}),machine,[-.13,1.56,.82]); for(const px of [-.77,.48])box(.025,1.85,.08,steel,machine,[px,1.55,.82]); const glass=mesh(new THREE.PlaneGeometry(1.24,1.86),material('#d5eee9',{transparent:true,opacity:.1,metalness:.3,roughness:.08,depthWrite:false}),machine,[-.13,1.55,.84]); box(.04,.5,.06,steel,machine,[.52,1.4,.88]); for(let r=0;r<4;r++)for(let c=0;c<3;c++)box(.045,.045,.035,steel,machine,[.60+c*.065,1.43-r*.07,.64]); for(let i=0;i<6;i++)box(.24,.014,.025,steel,machine,[.67,.55+i*.045,.58]); for(const px of [-.65,.65])box(.15,.09,.6,dark,machine,[px,.015,0]); box(.24, .38, .08, material('#81d9ba', {emissive: '#50a486', emissiveIntensity: .5}), machine, [.67, 1.7, .62]); for (let i = 0; i < 3; i++) box(.15, .05, .08, material('#e4d7ad'), machine, [.67, 1.3 - i * .1, .62]); box(.85, .3, .1, material('#050f10'), machine, [-.13, .36, .62]); const label = document.createElement('canvas'); label.width = 512; label.height = 128; const ctx = label.getContext('2d'); ctx.fillStyle = '#193c30'; ctx.fillRect(0, 0, 512, 128); ctx.fillStyle = '#d9efa7'; ctx.font = 'bold 57px Tahoma'; ctx.textAlign = 'center'; ctx.fillText('روبات مارکت', 256, 83); const sign = material('#ffffff', {map: new THREE.CanvasTexture(label), emissive: '#9cce91', emissiveIntensity: .18}); box(1.5, .38, .05, sign, machine, [0, 2.68, .58]); // A separate card, animated through the reader only after an explicit swipe. const bankCard=box(.24,.15,.014,material('#ddd4b4',{metalness:.2}),machine,[.9,1.8,.8]); box(.2,.03,.016,dark,bankCard,[0,.035,.008]);bankCard.visible=false; const pad = mesh(new THREE.CylinderGeometry(2.3, 2.4, .12, 32), material('#9caa80'), scene, [9, .05, stationZ]); const delivery = new THREE.Group(); scene.add(delivery); const deliveryBody=mesh(new THREE.CylinderGeometry(.1,.1,.28,10),bottleMats[0],delivery); box(.09,.05,.09,dark,delivery,[0,.16,0]); delivery.visible = false; // Picnic clearing, checked blanket, basket and a recycling bin. const picnic=new THREE.Group();picnic.position.set(23,1.8,pathZ(22));scene.add(picnic); for(let i=0;i<6;i++)for(let j=0;j<5;j++)box(.5,.035,.5,material((i+j)%2?'#f3dca9':'#b96f51'),picnic,[(i-2.5)*.5,.08,(j-2)*.5]); const picnicFood=new THREE.Group();picnic.add(picnicFood); box(.5,.3,.35,material('#ac8252'),picnic,[-1,.25,-.8]); mesh(new THREE.TorusGeometry(.22,.035,6,16,Math.PI),bark,picnic,[-1,.5,-.8]); const bin=box(.55,.8,.55,material('#39735b'),scene,[24,2.2,pathZ(22)-2.3]); box(.65,.08,.65,dark,scene,[24,2.64,pathZ(22)-2.3]); const flap=new THREE.Group();flap.position.set(-.13,.53,.69);machine.add(flap); box(.87,.28,.035,material('#527268',{metalness:.45}),flap,[0,-.14,0]); const glow=new THREE.PointLight('#a8ffc5',2,7);glow.position.set(9,2,stationZ+1);scene.add(glow); const butterfly=new THREE.Group();scene.add(butterfly); const wings=[-1,1].map(side=>{const wing=mesh(new THREE.SphereGeometry(.16,8,6),material('#efb75b'),butterfly,[side*.14,0,0]);wing.scale.set(1,.12,1.4);return wing;}); // A raised footbridge crosses a shallow stream before the vending stop. const bridgeX=0; const water=mesh(new THREE.PlaneGeometry(3.4,40),material('#68b4b6',{metalness:.3,roughness:.25}),scene,[bridgeX,.035,0]);water.rotation.x=-Math.PI/2; const bridge=new THREE.Group();scene.add(bridge); const wood=material('#a57b50'); for(let i=0;i<13;i++){const bx=-2+i/3;box(.29,.14,2.8,wood,bridge,[bx,.22,pathZ(bx)]);} for(const side of [-1,1]){ for(const bx of [-2,0,2])box(.12,1,.12,bark,bridge,[bx,.65,pathZ(bx)+side*1.3]); const rail=box(4.3,.1,.1,wood,bridge,[0,1.08,side*1.3]);rail.rotation.y=-.25; } function pathHeight(at){return Math.max(0,Math.min(1,(2.6-Math.abs(at))/.6))*.3+THREE.MathUtils.smoothstep(at,17,22)*1.8;} const rabbit=new THREE.Group();scene.add(rabbit); const fur=material('#eee2ca'),pink=material('#d5a193'); const rabbitBody=ball(.28,fur,rabbit,[0,.3,0]);rabbitBody.scale.set(1.3,1,1); ball(.22,fur,rabbit,[.24,.55,0]);ball(.12,fur,rabbit,[-.35,.4,0]); for(const side of [-1,1]){const ear=ball(.1,fur,rabbit,[.23,.86,side*.1]);ear.scale.set(.75,2.5,.75);const inset=ball(.065,pink,rabbit,[.29,.88,side*.1]);inset.scale.set(.4,2.6,.7);ball(.025,dark,rabbit,[.43,.58,side*.13]);ball(.09,fur,rabbit,[.1,.1,side*.2]);} const lostPaper=box(.6,.025,.43,material('#fff3d5'),scene,[14,.35,Math.sin(14*.17)*1.5+1.5]);lostPaper.rotation.z=.15; const bush=ball(.55,material('#688a4b'),scene,[14,.3,Math.sin(14*.17)*1.5+2]); const oldTree=new THREE.Group();oldTree.position.set(19,0,-5);scene.add(oldTree);mesh(new THREE.CylinderGeometry(.5,.8,4,9),bark,oldTree,[0,2,0]);ball(2,material('#54734a'),oldTree,[0,4.8,0]); const hill=mesh(new THREE.SphereGeometry(5,24,16),green,scene,[23,-1.3,0]);hill.scale.set(1,.65,1.3); let branchPath=null; function rebuildRoute(){ if(branchPath){scene.remove(branchPath);branchPath.geometry.dispose();branchPath.material.dispose();} const pts=[];for(let at=11;at<=24;at+=.15)pts.push(new THREE.Vector3(at,pathHeight(at)+.045,pathZ(at))); branchPath=mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts),80,.8,8,false),material('#cbb889')); // Flatten only the cross-section, retaining the hill's elevation. const positions=branchPath.geometry.attributes.position; for(let i=0;idocument.querySelector(s),shop=$('#shop'),ending=$('#ending'); const storyDialog=$('#story-dialog'),notebook=$('#notebook'); function renderChapter(){ const chapter=chapters[chapterId];$('#chapter-title').textContent=chapter.title;$('#chapter-line').textContent=chapter.lines[chapterLine];$('#chapter-count').textContent=`${chapterLine+1} / ${chapter.lines.length}`;$('#chapter-actions').replaceChildren(); const last=chapterLine===chapter.lines.length-1; if(last&&chapter.action){const b=document.createElement('button');b.type='button';b.textContent=`${chapter.action.label} · F`;b.onclick=performStoryAction;$('#chapter-actions').append(b);} chapterTimer=Math.max(2.5,chapter.lines[chapterLine].length*.055); } function performStoryAction(){ if(!chapterId||shop.open||notebook.open||ending.open||state.busy)return; const chapter=chapters[chapterId]; if(chapter.action&&chapterLine===chapter.lines.length-1)finishChapter(chapter.action.value); } function showChapter(id){chapterId=id;chapterLine=0;storyDialog.hidden=false;renderChapter();} function finishChapter(choice){ const id=chapterId;if(!completeChapter(story,id,choice))return; if(id==='rabbit'){rabbitSeen=true;encounterTime=0;} if(id==='fork'||id==='wind')rebuildRoute(); storyDialog.hidden=true;chapterId=null;syncHUD(); if(id==='summit'){drawMemory($('#memory'));$('#ending-discoveries').textContent=discoveries(story).join(' · ');ending.showModal();} } function drawMemory(canvas){ const c=canvas.getContext('2d');c.clearRect(0,0,600,360);c.fillStyle=story.sky==='gold'?'#f5d18b':story.sky==='rose'?'#eeb9ba':'#b9dfeb';c.fillRect(0,0,600,360);c.fillStyle='#fff0af';c.beginPath();c.arc(500,65,30,0,Math.PI*2);c.fill();c.fillStyle='#84a36b';c.beginPath();c.moveTo(0,240);c.quadraticCurveTo(160,90,320,235);c.quadraticCurveTo(460,120,600,210);c.lineTo(600,360);c.lineTo(0,360);c.fill(); for(let i=0;i<5;i++){c.fillStyle='#6a553e';c.fillRect(30+i*115,180,8,75);c.fillStyle='#456b47';c.beginPath();c.moveTo(5+i*115,205);c.lineTo(34+i*115,125);c.lineTo(63+i*115,205);c.fill();} c.strokeStyle='#8a674d';c.lineWidth=8;c.beginPath();c.moveTo(75,278);c.lineTo(210,278);c.moveTo(85,263);c.lineTo(200,263);c.stroke(); function person(px,py,size,color){c.fillStyle='#edbb94';c.beginPath();c.arc(px,py,size*.18,0,Math.PI*2);c.fill();c.strokeStyle=color;c.lineWidth=size*.16;c.beginPath();c.moveTo(px,py+size*.22);c.lineTo(px,py+size*.65);c.moveTo(px,py+size*.3);c.lineTo(px+size*.3,py+size*.48);c.moveTo(px,py+size*.65);c.lineTo(px-size*.15,py+size);c.moveTo(px,py+size*.65);c.lineTo(px+size*.15,py+size);c.stroke();} person(300,200,90,'#b28b48');person(347,235,58,'#c56f4d');c.strokeStyle='#d4a77d';c.lineWidth=5;c.beginPath();c.moveTo(327,243);c.lineTo(347,252);c.stroke();c.font='20px Tahoma';c.textAlign='center';c.fillStyle='#294b3c';c.fillText(story.sky?'من و بابا، تپهٔ آرزوها':'من و بابا… ادامه دارد',300,338); } $('#notebook-open').onclick=()=>{clearInput();$('#discoveries').textContent=discoveries(story).join(' · ')||'هنوز اول راهیم؛ کشف‌های امروز اینجا ثبت می‌شن.';drawMemory($('#drawing'));notebook.showModal();};$('#notebook-close').onclick=()=>notebook.close();notebook.addEventListener('close',clearInput); let payment=createPayment(),paymentTime=0; let state=createState(),x=-8,moving=0,gait=0,deliveryTime=0,activeProduct=null,elapsed=0; const pressed=new Set(),held=new Set(); const fa=n=>n.toLocaleString('fa-IR'); let audio=null,soundEnabled=false,nextBird=0,zoomDistance=1; function tone(frequency,duration=.12,volume=.04){if(!soundEnabled||!audio)return;const oscillator=audio.createOscillator(),gain=audio.createGain();oscillator.frequency.setValueAtTime(frequency,audio.currentTime);oscillator.frequency.exponentialRampToValueAtTime(frequency*.7,audio.currentTime+duration);gain.gain.setValueAtTime(volume,audio.currentTime);gain.gain.exponentialRampToValueAtTime(.001,audio.currentTime+duration);oscillator.connect(gain).connect(audio.destination);oscillator.start();oscillator.stop(audio.currentTime+duration);} $('#sound').onclick=async()=>{try{audio??=new AudioContext();await audio.resume();soundEnabled=!soundEnabled;$('#sound').textContent=soundEnabled?'صدا: روشن ♫':'صدا: خاموش ♫';$('#sound').setAttribute('aria-pressed',String(soundEnabled));tone(700);}catch{$('#sound').textContent='صدا در دسترس نیست';}}; function syncHUD(){ $('#balance').textContent=fa(state.balance); $('#basket').textContent=state.basket.length?'🧺 '+state.basket.map(p=>p.icon).join(' '):'🧺 سبد پیک‌نیک خالی است'; $('#mission').textContent=!story.done.includes('courage')?'مسیر تپه را پیدا کنید':!story.done.includes('paper')&&story.done.includes('wind')?'نقاشی را کنار بوته پیدا کنید':story.done.includes('paper')&&!story.route?'برای نقاشی یک جای قشنگ پیدا کنید':readyForPicnic(state)?'سبد آماده است؛ به زیرانداز برسید':state.basket.some(p=>p.type==='drink')?'یک خوراکی هم برای پیک‌نیک بردارید':'یک نوشیدنی و یک خوراکی تهیه کنید'; } function selectChannel(code){ $('#product-code').value=code;$('#product-code').removeAttribute('aria-invalid');$('#shop-status').textContent=''; document.querySelectorAll('[data-channel]').forEach(node=>node.classList.toggle('selected',node.dataset.channel===code)); const row=$(`#products [data-channel="${code}"]`);row?.scrollIntoView({block:'nearest'}); } function renderProducts(){ syncHUD();$('#products').replaceChildren();$('#channels').replaceChildren(); products.forEach(p=>{const button=document.createElement('button');button.type='button';button.textContent=fa(p.channel);button.dataset.channel=p.code;button.disabled=!p.available;button.setAttribute('aria-label',`کانال ${fa(p.channel)}${p.available?'':'؛ غیرفعال'}`);button.onclick=()=>selectChannel(p.code);$('#channels').append(button);}); products.forEach(p=>{const card=document.createElement('button');card.type='button';card.className='product-code-card';card.dataset.channel=p.code;card.innerHTML=`${p.icon}کد ${fa(p.channel)} · ${p.name}محصول فرضی کانال ${fa(p.channel)}${formatHetoman(p.cost)}`;card.onclick=()=>selectChannel(p.code);$('#products').append(card);}); } function deliver(p){ activeProduct=p;deliveryTime=3.4;delivery.visible=true;deliveryBody.material=bottleMats[p.type==='snack'?2:p.baseId==='water'?1:0]; renderProducts();shop.close();pressed.clear();held.clear();tone(480,.3); $('#speech').textContent='پدر: ببین، دستگاه داره انتخابت رو آماده می‌کنه!'; } function openShop(){if(Math.abs(x-stationX)<2.5&&!shop.open&&!notebook.open&&!state.busy&&!state.finished){pressed.clear();held.clear();$('#shop-status').textContent='بچه: خودم می‌تونم انتخاب کنم؟ پدر: حتماً! برای نوشیدنی و خوراکی پول کنار بگذار.';renderProducts();$('#product-code').value='';$('#product-code').removeAttribute('aria-invalid');shop.showModal();$('#product-code').focus();tone(750);}} $('#code-form').onsubmit=e=>{ e.preventDefault(); if(state.busy||state.finished||!shop.open)return; const field=$('#product-code'),product=productByCode(field.value); let error=''; if(!field.value.trim())error='کد محصول را وارد کن.'; else if(!product)error='کانال قابل خرید نیست؛ عددی از ۲۱ تا ۶۰ وارد کن.'; else if(state.balance{if(cancelPayment(state,payment)){$('#payment-panel').hidden=true;bankCard.visible=false;clearInput();}}; $('#product-code').addEventListener('input',()=>{document.querySelectorAll('[data-channel]').forEach(node=>node.classList.remove('selected'));$('#product-code').removeAttribute('aria-invalid');$('#shop-status').textContent='';}); $('#interact').onclick=openShop;$('#close').onclick=()=>shop.close(); window.addEventListener('keydown',e=>{if(e.key.toLowerCase()==='f'){if(!e.repeat)performStoryAction();return;}if(e.key.toLowerCase()==='c'&&payment.phase==='ready'){e.preventDefault();swipeCard();return;}if(notebook.open||ending.open)return;if(['ArrowRight','ArrowLeft','ArrowUp','ArrowDown',' '].includes(e.key)&&!shop.open&&!ending.open)e.preventDefault();pressed.add(e.key.toLowerCase());if(e.key.toLowerCase()==='e')openShop();}); window.addEventListener('keyup',e=>pressed.delete(e.key.toLowerCase())); function clearInput(){pressed.clear();held.clear();}window.addEventListener('blur',clearInput);document.addEventListener('visibilitychange',clearInput);shop.addEventListener('close',clearInput); for(const [id,dir] of [['back',-1],['forward',1],['zoom-in','in'],['zoom-out','out']]){const b=$('#'+id);b.onpointerdown=e=>{if((state.busy||state.finished)&&typeof dir==='number')return;b.setPointerCapture(e.pointerId);held.add(dir);};b.onpointerup=b.onpointercancel=b.onlostpointercapture=()=>held.delete(dir);} function reset(){payment=createPayment();paymentTime=0;$('#payment-panel').hidden=true;bankCard.visible=false;story=createStory();chapterId=null;storyDialog.hidden=true;chapterTimer=0;if(notebook.open)notebook.close();rebuildRoute();state=createState();x=-8;deliveryTime=0;delivery.visible=false;activeProduct=null;flap.rotation.x=0;encounterTime=0;rabbitSeen=false;encounterActive=false;facing=Math.PI/2;zoomDistance=1;picnicFood.clear();clearInput();if(shop.open)shop.close();if(ending.open)ending.close();$('#badge').textContent='';$('#recycle').disabled=false;$('#recycle').textContent='بسته‌ها را در سطل بینداز ♻';$('#speech').textContent='بچه: بابا، برای پیک‌نیک چی بخریم؟';syncHUD();} $('#restart').onclick=reset;$('#again').onclick=reset; $('#recycle').onclick=()=>{state.recycled=true;$('#badge').textContent='🏅 نشان دوست جنگل را گرفتی!';$('#recycle').disabled=true;$('#recycle').textContent='جنگل تمیز ماند ✓';$('#speech').textContent='بچه: چیزی جا نذاشتیم؟ پدر: فقط رد پامون.';tone(880,.4);}; function finish(){state.finished=true;clearInput();state.basket.slice(0,10).forEach((p,i)=>{const item=mesh(p.type==='drink'?new THREE.CylinderGeometry(.09,.09,.3,8):new THREE.BoxGeometry(.25,.09,.2),bottleMats[p.type==='drink'?0:2],picnicFood,[-.5+(i%5)*.28,.25,Math.floor(i/5)*.3]);});$('#ending-text').textContent=`با ${fa(state.basket.length)} خوراکی و ${formatHetoman(state.balance)} باقی‌مانده، وقت یک استراحت دونفره است.`;$('#ending-basket').textContent=state.basket.map(p=>p.icon).join(' ');$('#speech').textContent='بچه: بهترین قسمت گردش، با تو بودن بود بابا!';$('#mission').textContent='پیک‌نیک آماده است؛ یک خاطرهٔ خوب ساختید';tone(660,.5);showChapter('summit');} // The ending opens on request so the picnic remains visible before the overlay. const celebrate=document.createElement('button');celebrate.id='celebrate';celebrate.textContent='پیک‌نیک و نشان دوست جنگل 🌿';celebrate.hidden=true;document.body.append(celebrate);celebrate.onclick=()=>{if(!story.done.includes('summit'))showChapter('summit');else ending.showModal();}; ending.addEventListener('cancel',clearInput); function resize(){renderer.setSize(innerWidth,innerHeight);camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();}window.addEventListener('resize',resize);resize(); const camTarget=new THREE.Vector3(),look=new THREE.Vector3(x+1,.8,pathZ(x)),lookTarget=new THREE.Vector3();camera.position.set(x+7,10,14); let last=performance.now(); function frame(now){ requestAnimationFrame(frame);const dt=Math.min((now-last)/1000,.1);last=now;elapsed+=dt; const direction=movementDirection(pressed,held); const next=nextChapter(story,x);if(next&&!chapterId&&!shop.open&&!ending.open&&!notebook.open&&!state.busy&&!state.finished)showChapter(next); if(chapterId&&!shop.open&&!notebook.open&&!ending.open&&!document.hidden){ const chapter=chapters[chapterId],lastLine=chapterLine===chapter.lines.length-1; if(!(lastLine&&chapter.action)){ chapterTimer-=dt; if(chapterTimer<=0){if(!lastLine){chapterLine++;renderChapter();}else finishChapter();} } } encounterActive=chapterId==='rabbit'; moving=canWalk({shopOpen:shop.open,notebookOpen:notebook.open,endingOpen:ending.open,busy:state.busy,finished:state.finished})?direction:0; x=THREE.MathUtils.clamp(x+moving*2.2*dt,-9,22); family.position.set(x,pathHeight(x),pathZ(x));family.rotation.y=-Math.atan(.255*Math.cos(x*.17)); const shopping=shop.open||state.busy; if(shopping){family.position.x=THREE.MathUtils.lerp(x,stationX,1-Math.exp(-dt*8));x=family.position.x;} const desiredFacing=shopping?Math.PI:moving<0?-Math.PI/2:moving>0?Math.PI/2:facing; facing+=Math.atan2(Math.sin(desiredFacing-facing),Math.cos(desiredFacing-facing))*(1-Math.exp(-dt*7)); if(moving)gait+=dt*6.2; characters.update({dt,time:elapsed,gait,moving:!!moving,facing,shopping,delivery:state.busy,finished:state.finished,watchRabbit:encounterActive}); if(state.finished)family.position.x=22.7; rabbit.position.set(-2.5+(rabbitSeen?Math.min(4,encounterTime)*.8:0),rabbitSeen?Math.abs(Math.sin(elapsed*8))*.3:0,pathZ(-2.5)+2.5+(rabbitSeen?Math.min(4,encounterTime):0)); if(rabbitSeen)encounterTime=Math.min(4,encounterTime+dt); camTarget.set(x+7,state.finished?8:innerWidth<650?12:9,innerWidth<650?19:13); lookTarget.set(x+1,.8+pathHeight(x),pathZ(x)); if(shopping){camTarget.set(stationX+4.2,3.5,stationZ+(innerWidth<650?8:6));lookTarget.set(stationX,.95,stationZ+.9);} if(encounterActive){camTarget.set(x+5,5.5,pathZ(x)+9);lookTarget.set(-2.9,.65,pathZ(-2.5)+1);} if(!shop.open&&!notebook.open&&!ending.open){ const zoomInput=Number(pressed.has('arrowdown')||held.has('out'))-Number(pressed.has('arrowup')||held.has('in')); zoomDistance=THREE.MathUtils.clamp(zoomDistance+zoomInput*dt*.45,.6,1.6); } camTarget.sub(lookTarget).multiplyScalar(zoomDistance).add(lookTarget); if(['approaching','ready','approved'].includes(payment.phase)){ const reader=new THREE.Vector3(.67,1.7,.68);machine.localToWorld(reader); lookTarget.copy(reader);camTarget.copy(reader).add(new THREE.Vector3(.8,.35,1.8)); } camera.position.lerp(camTarget,1-Math.exp(-dt*2.8));look.lerp(lookTarget,1-Math.exp(-dt*3));camera.lookAt(look); document.body.classList.toggle('cinematic',shopping||encounterActive); const near=Math.abs(x-stationX)<2.5; // Project the prompt from the reader into screen space; it follows the machine. camera.updateMatrixWorld();machine.updateMatrixWorld(true); const promptPoint=machine.localToWorld(new THREE.Vector3(.55,2.15,.9)).project(camera); const promptVisible=near&&!shop.open&&!notebook.open&&!ending.open&&!state.busy&&!state.finished&&promptPoint.z>-1&&promptPoint.z<1; $('#interact').hidden=!promptVisible; if(promptVisible){$('#interact').style.left=`${THREE.MathUtils.clamp((promptPoint.x*.5+.5)*innerWidth,85,innerWidth-85)}px`;$('#interact').style.top=`${THREE.MathUtils.clamp((-.5*promptPoint.y+.5)*innerHeight,85,innerHeight-100)}px`;} $('#machine-note').hidden=!promptVisible; document.body.classList.toggle('near-machine',promptVisible); celebrate.hidden=!state.finished; $('#progress').style.width=`${Math.min(100,(state.basket.some(p=>p.type==='drink')?30:0)+(state.basket.some(p=>p.type==='snack')?30:0)+(state.finished?40:Math.max(0,(x-11)/11)*35))}%`; if(!state.busy&&!state.finished&&!encounterActive){ $('#speech').textContent=near?(readyForPicnic(state)?'بچه: همه‌چی داریم! بریم سر زیرانداز؟':'بچه: بابا، این فروشنده نداره؟ پدر: خودت انتخاب کن، دستگاه بهت می‌ده!'):x>20&&!readyForPicnic(state)?'پدر: برای پیک‌نیک یک نوشیدنی و یک خوراکی لازم داریم؛ برگردیم دستگاه.':x>12?'بچه: اون زیرانداز رو ببین! رسیدیم؟':x>0?'پدر: صدای پرنده‌ها رو می‌شنوی؟ آروم راه بریم.':'بچه: بابا، برای پیک‌نیک چی بخریم؟'; } if(encounterActive)$('#speech').textContent='بچه: بابا نگاه کن! یه خرگوش کوچولو!'; if(x>21&&storyReady(story)&&readyForPicnic(state)&&!state.busy&&!state.finished)finish(); if(payment.phase!=='idle'){ paymentTime+=dt; if(payment.phase==='approaching'&&paymentTime>1.2){payment.phase='ready';$('#swipe-card').disabled=false;$('#cancel-payment').hidden=false;$('#payment-result').textContent='دکمهٔ «کشیدن کارت» یا کلید C را بزن.';} if(payment.phase==='approved'){ bankCard.position.set(.92-Math.min(paymentTime,1)*.35,1.86-Math.min(paymentTime,1)*.32,.78); if(paymentTime>1.35){const product=payment.product;payment=createPayment();$('#payment-panel').hidden=true;bankCard.visible=false;deliver(product);} } } if(deliveryTime>0){ deliveryTime=Math.max(0,deliveryTime-dt);const t=3.4-deliveryTime; const slotIndex=(activeProduct?.channel??22)-1; const start=new THREE.Vector3(-.65+(slotIndex%10)*.115,.965+(5-Math.floor(slotIndex/10))*.275,.76);machine.localToWorld(start); const slot=new THREE.Vector3(-.13,.35,.86);machine.localToWorld(slot); const hand=new THREE.Vector3(.5,.65,-.15);family.localToWorld(hand); if(t<1.1)delivery.position.lerpVectors(start,slot,t/1.1); else if(t<1.8){delivery.position.copy(slot);} else delivery.position.lerpVectors(slot,hand,Math.min(1,(t-1.8)/1.3)); flap.rotation.x=t>1&&t<2.6?-1:0; if(!deliveryTime){state.busy=false;delivery.visible=false;flap.rotation.x=0;tone(980,.18);syncHUD();} } lostPaper.visible=!story.done.includes('paper');lostPaper.rotation.y=Math.sin(elapsed)*.15; fishes.forEach((fish,i)=>fish.position.z=Math.sin(elapsed*.8+i)*.8); trees.forEach((tree,i)=>{tree.rotation.z=Math.sin(elapsed*.65+i)*.012;}); butterfly.position.set(x+1.6+Math.sin(elapsed)*.5,2+Math.sin(elapsed*1.4)*.4,pathZ(x)+1.8);wings.forEach((wing,i)=>wing.rotation.z=Math.sin(elapsed*15)*(i?1:-1)*.7); if(soundEnabled&&elapsed>nextBird){tone(1500+Math.random()*900,.2,.018);nextBird=elapsed+3+Math.random()*4;} renderer.render(scene,camera); } $('#loading').remove();syncHUD();requestAnimationFrame(frame);