Files
vending-machine-game/src/main.js
T
2026-10-20 11:41:30 +03:30

393 lines
31 KiB
JavaScript

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;i<positions.count;i++){const px=positions.getX(i);positions.setY(i,pathHeight(px)+.045);}
positions.needsUpdate=true;branchPath.geometry.computeVertexNormals();
}
rebuildRoute();
const fishes=[];for(let i=0;i<4;i++){const fish=ball(.09,material('#e8b365'),scene,[.4,.11,-.6+i*.4]);fish.scale.set(2,.5,1);fishes.push(fish);}
let encounterTime=0,rabbitSeen=false,encounterActive=false,facing=Math.PI/2;
const $=s=>document.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=`<span class="icon">${p.icon}</span><b>کد ${fa(p.channel)} · ${p.name}<small>محصول فرضی کانال ${fa(p.channel)}</small></b><span>${formatHetoman(p.cost)}</span>`;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<product.cost)error='برای این محصول موجودی کافی نداری.';
else if(!purchase({...state,basket:[...state.basket]},product.id))error='برای تکمیل پیک‌نیک، هزینهٔ نوشیدنی و خوراکی را کنار بگذار؛ محصول دیگری انتخاب کن.';
if(error){$('#shop-status').textContent=error;field.setAttribute('aria-invalid','true');field.focus();tone(200,.2);return;}
field.removeAttribute('aria-invalid');
if(beginPayment(state,payment,product)){
paymentTime=0;shop.close();clearInput();$('#payment-panel').hidden=false;$('#cancel-payment').hidden=false;
$('#payment-message').textContent=`کانال ${fa(product.channel)} · ${product.name} · ${formatHetoman(product.cost)}`;
$('#swipe-card').disabled=true;$('#payment-result').textContent='در حال نزدیک شدن به کارت‌خوان…';
}
};
function swipeCard(){
if(!swipePayment(state,payment))return;
paymentTime=0;bankCard.visible=true;$('#swipe-card').disabled=true;$('#cancel-payment').hidden=true;
$('#payment-result').textContent='خرید موفق ✓ محصول در حال تحویل است';tone(1100,.3);syncHUD();
}
$('#swipe-card').onclick=swipeCard;
$('#cancel-payment').onclick=()=>{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);