From 430d7e1c11a31f069c09c06bff6b9531d1380e4c Mon Sep 17 00:00:00 2001 From: "hamed@B" Date: Tue, 20 Oct 2026 12:07:26 +0330 Subject: [PATCH] update --- .idea/vcs.xml | 6 + src/characters.js | 45 +- src/controls.js | 3 +- src/game-state.js | 38 +- src/main.js | 953 +++++++++++++++++++++++++++++++----------- src/payment.js | 44 +- src/story.js | 93 +++-- src/style.css | 1004 ++++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 1848 insertions(+), 338 deletions(-) create mode 100644 .idea/vcs.xml diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/characters.js b/src/characters.js index 6d93a72..2facaa0 100644 --- a/src/characters.js +++ b/src/characters.js @@ -41,26 +41,29 @@ export function createFamily() { root.add(torso); const cloth = mat(isChild ? '#d98150' : '#e4c695'), pants = mat(isChild ? '#527463' : '#496474'); // Tapered jacket, hips and neck replace the single rounded toy-like body. - shape(torso,new THREE.CylinderGeometry(.255*scale,.22*scale,.62*scale,16),cloth,[0,1.2*scale,0]).scale.z=.72; - sphere(torso,.23,pants,[0,.88*scale,0],[scale,.55*scale,.72*scale]); - shape(torso,new THREE.CylinderGeometry(.075*scale,.09*scale,.18*scale,12),skin,[0,1.59*scale,0]); - const seam=shape(torso,new THREE.BoxGeometry(.014*scale,.5*scale,.014*scale),mat('#bda77f'),[0,1.2*scale,.19*scale]); - for(const side of [-1,1]){ - const collar=shape(torso,new THREE.BoxGeometry(.12*scale,.09*scale,.025*scale),cloth,[side*.07*scale,1.49*scale,.15*scale]);collar.rotation.z=side*.4; + shape(torso, new THREE.CylinderGeometry(.255 * scale, .22 * scale, .62 * scale, 16), cloth, [0, 1.2 * scale, 0]).scale.z = .72; + sphere(torso, .23, pants, [0, .88 * scale, 0], [scale, .55 * scale, .72 * scale]); + shape(torso, new THREE.CylinderGeometry(.075 * scale, .09 * scale, .18 * scale, 12), skin, [0, 1.59 * scale, 0]); + const seam = shape(torso, new THREE.BoxGeometry(.014 * scale, .5 * scale, .014 * scale), mat('#bda77f'), [0, 1.2 * scale, .19 * scale]); + for (const side of [-1, 1]) { + const collar = shape(torso, new THREE.BoxGeometry(.12 * scale, .09 * scale, .025 * scale), cloth, [side * .07 * scale, 1.49 * scale, .15 * scale]); + collar.rotation.z = side * .4; } const head = new THREE.Group(); head.position.y = 1.77 * scale; torso.add(head); - head.scale.setScalar(isChild?.9:.77); - const eyes=[]; + head.scale.setScalar(isChild ? .9 : .77); + const eyes = []; sphere(head, .27 * scale, skin, [0, 0, 0]); sphere(head, .275 * scale, hair, [0, .11 * scale, -.035 * scale], [1, .65, 1]); sphere(head, .058 * scale, skin, [0, -.02 * scale, .25 * scale]); for (const side of [-1, 1]) { sphere(head, .06 * scale, skin, [side * .26 * scale, 0, 0]); - sphere(head,.037*scale,mat('#f6f1e7'),[side*.09*scale,.03*scale,.241*scale],[1,.65,.5]); - const eye=sphere(head,.022*scale,shoes,[side*.09*scale,.03*scale,.258*scale],[.8,1,.45]);eyes.push(eye); - const brow=shape(head,new THREE.BoxGeometry(.075*scale,.015*scale,.015*scale),hair,[side*.09*scale,.087*scale,.237*scale]);brow.rotation.z=side*-.08; + sphere(head, .037 * scale, mat('#f6f1e7'), [side * .09 * scale, .03 * scale, .241 * scale], [1, .65, .5]); + const eye = sphere(head, .022 * scale, shoes, [side * .09 * scale, .03 * scale, .258 * scale], [.8, 1, .45]); + eyes.push(eye); + const brow = shape(head, new THREE.BoxGeometry(.075 * scale, .015 * scale, .015 * scale), hair, [side * .09 * scale, .087 * scale, .237 * scale]); + brow.rotation.z = side * -.08; sphere(head, .04 * scale, mat('#d79379'), [side * .15 * scale, -.055 * scale, .215 * scale]); } const smile = shape(head, new THREE.TorusGeometry(.068 * scale, .012 * scale, 6, 14, Math.PI), shoes, [0, -.075 * scale, .252 * scale]); @@ -89,31 +92,31 @@ export function createFamily() { lower: segment(group, null, null, .061 * scale, skin), hand: sphere(group, .075 * scale, skin, [0, 0, 0]) })); - return {root, torso, head, legs, arms, scale, z,eyes}; + return {root, torso, head, legs, arms, scale, z, eyes}; } const father = makePerson(false, -.45), child = makePerson(true, .45); - function update({time, gait, moving, facing, shopping, delivery, finished, watchRabbit,dt=1/60}) { + function update({time, gait, moving, facing, shopping, delivery, finished, watchRabbit, dt = 1 / 60}) { const actors = [father, child]; actors.forEach((actor, index) => { - const blend=1-Math.exp(-dt*7); - actor.root.position.x=THREE.MathUtils.lerp(actor.root.position.x,shopping?(index===0?-.55:.25):0,blend); - actor.root.position.y=THREE.MathUtils.lerp(actor.root.position.y,finished?-.32:0,blend); - actor.root.position.z=actor.z; + const blend = 1 - Math.exp(-dt * 7); + actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, shopping ? (index === 0 ? -.55 : .25) : 0, blend); + actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.32 : 0, blend); + actor.root.position.z = actor.z; actor.root.rotation.y = facing; const bob = moving ? Math.abs(Math.sin(gait)) * .022 : Math.sin(time * 2) * .006; actor.torso.position.y = bob; - actor.torso.rotation.z=moving?Math.sin(gait)*.018:0; - const blinking=Math.sin(time*1.3+index)>.993; - actor.eyes.forEach(eye=>eye.scale.y=blinking?.12:1); + actor.torso.rotation.z = moving ? Math.sin(gait) * .018 : 0; + const blinking = Math.sin(time * 1.3 + index) > .993; + actor.eyes.forEach(eye => eye.scale.y = blinking ? .12 : 1); actor.head.rotation.y = shopping ? -.65 : watchRabbit ? .55 : Math.sin(time * .7) * .06; actor.torso.rotation.x = delivery && index === 1 ? .22 : 0; actor.legs.forEach((leg, i) => { const phase = gait + i * Math.PI; leg.hip.rotation.x = finished ? -1.3 : moving ? Math.sin(phase) * .34 : 0; - leg.foot.rotation.x=moving?Math.sin(phase+.5)*.12:0; + leg.foot.rotation.x = moving ? Math.sin(phase + .5) * .12 : 0; leg.knee.rotation.x = finished ? 1.5 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05; }); }); diff --git a/src/controls.js b/src/controls.js index 1ef757a..5f3a564 100644 --- a/src/controls.js +++ b/src/controls.js @@ -3,6 +3,7 @@ export function movementDirection(pressed, held) { return Number(pressed.has('arrowright') || pressed.has('d') || held.has(1)) - Number(pressed.has('arrowleft') || pressed.has('a') || held.has(-1)); } -export function canWalk({shopOpen=false, notebookOpen=false, endingOpen=false, busy=false, finished=false}) { + +export function canWalk({shopOpen = false, notebookOpen = false, endingOpen = false, busy = false, finished = false}) { return !shopOpen && !notebookOpen && !endingOpen && !busy && !finished; } diff --git a/src/game-state.js b/src/game-state.js index 41811dd..8a5e8e2 100644 --- a/src/game-state.js +++ b/src/game-state.js @@ -3,23 +3,31 @@ export const formatHetoman = value => `${value.toLocaleString('fa-IR')} هتوم export const hetomanToRial = value => value * 10000; const assortment = [ - {id: 'juice', code:'11', name: 'آب‌میوه', icon: '🧃', cost: 35000, type: 'drink'}, - {id: 'water', code:'12', name: 'آب معدنی', icon: '💧', cost: 15000, type: 'drink'}, - {id: 'biscuit', code:'13', name: 'بیسکویت', icon: '🍪', cost: 25000, type: 'snack'}, - {id:'milk',code:'14',name:'شیر کاکائو',icon:'🥛',cost:40000,type:'drink'}, - {id:'tea',code:'15',name:'چای سرد',icon:'🧋',cost:45000,type:'drink'}, - {id:'lemonade',code:'16',name:'لیموناد',icon:'🍋',cost:40000,type:'drink'}, - {id:'chocolate',code:'17',name:'شکلات',icon:'🍫',cost:30000,type:'snack'}, - {id:'nuts',code:'18',name:'آجیل',icon:'🥜',cost:65000,type:'snack'}, - {id:'cake',code:'19',name:'کیک',icon:'🧁',cost:35000,type:'snack'} + {id: 'juice', code: '11', name: 'آب‌میوه', icon: '🧃', cost: 35000, type: 'drink'}, + {id: 'water', code: '12', name: 'آب معدنی', icon: '💧', cost: 15000, type: 'drink'}, + {id: 'biscuit', code: '13', name: 'بیسکویت', icon: '🍪', cost: 25000, type: 'snack'}, + {id: 'milk', code: '14', name: 'شیر کاکائو', icon: '🥛', cost: 40000, type: 'drink'}, + {id: 'tea', code: '15', name: 'چای سرد', icon: '🧋', cost: 45000, type: 'drink'}, + {id: 'lemonade', code: '16', name: 'لیموناد', icon: '🍋', cost: 40000, type: 'drink'}, + {id: 'chocolate', code: '17', name: 'شکلات', icon: '🍫', cost: 30000, type: 'snack'}, + {id: 'nuts', code: '18', name: 'آجیل', icon: '🥜', cost: 65000, type: 'snack'}, + {id: 'cake', code: '19', name: 'کیک', icon: '🧁', cost: 35000, type: 'snack'} ]; // All 60 physical channels exist; only 21–60 are available in this scene. -export const channels=Array.from({length:60},(_,index)=>{ - const channel=index+1,base=assortment[(channel-22+63)%assortment.length]; - return {...base,baseId:base.id,id:`channel-${channel}`,code:String(channel),channel,cost:base.cost/1000,available:channel>=21}; +export const channels = Array.from({length: 60}, (_, index) => { + const channel = index + 1, base = assortment[(channel - 22 + 63) % assortment.length]; + return { + ...base, + baseId: base.id, + id: `channel-${channel}`, + code: String(channel), + channel, + cost: base.cost / 1000, + available: channel >= 21 + }; }); -export const products=channels.filter(channel=>channel.available); +export const products = channels.filter(channel => channel.available); export function createState() { return {balance: INITIAL_BALANCE, basket: [], busy: false, finished: false, recycled: false}; @@ -43,6 +51,6 @@ export function purchase(state, id) { } export function productByCode(value) { - const normalized=String(value).trim().replace(/[۰-۹]/g,c=>String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c))).replace(/[٠-٩]/g,c=>String('٠١٢٣٤٥٦٧٨٩'.indexOf(c))); - return products.find(p=>p.code===normalized) ?? null; + const normalized = String(value).trim().replace(/[۰-۹]/g, c => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c))).replace(/[٠-٩]/g, c => String('٠١٢٣٤٥٦٧٨٩'.indexOf(c))); + return products.find(p => p.code === normalized) ?? null; } diff --git a/src/main.js b/src/main.js index af80307..36a8727 100644 --- a/src/main.js +++ b/src/main.js @@ -1,10 +1,10 @@ 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 {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'; +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'); @@ -31,7 +31,7 @@ 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; +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'); @@ -57,7 +57,7 @@ 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) + return Math.sin(x * .17) * 1.5 + routeOffset(story, x) } const points = []; @@ -72,7 +72,8 @@ function random() { return seed / 4294967296 } -const trees=[]; +const trees = []; + function tree(x, z, s) { const g = new THREE.Group(); g.position.set(x, 0, z); @@ -88,7 +89,7 @@ function tree(x, z, s) { 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) + 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; @@ -102,8 +103,8 @@ for (let i = 0; i < 100; i++) { ball(.065, material(i % 3 ? '#eadba0' : '#e4ae89'), scene, [x, .22, z]); } -const characters=createFamily(); -const family=characters.group; +const characters = createFamily(); +const family = characters.group; scene.add(family); // Procedural vending machine, with a canvas sign and visible product rows. @@ -116,29 +117,49 @@ 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 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 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]); +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]); @@ -155,238 +176,674 @@ 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 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]); +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;}); +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; +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)]); } -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();} +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 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); +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; } -$('#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')?'یک خوراکی هم برای پیک‌نیک بردارید':'یک نوشیدنی و یک خوراکی تهیه کنید'; + +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]); } -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'}); +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(); } -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);}); + +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); } -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='پدر: ببین، دستگاه داره انتخابت رو آماده می‌کنه!'; +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 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 { + 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 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(); + +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') ? 'یک خوراکی هم برای پیک‌نیک بردارید' : 'یک نوشیدنی و یک خوراکی تهیه کنید'; } -$('#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');} + +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 < 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); +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(); } -$('#loading').remove();syncHUD();requestAnimationFrame(frame); + +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); diff --git a/src/payment.js b/src/payment.js index e1178d9..1573225 100644 --- a/src/payment.js +++ b/src/payment.js @@ -1,16 +1,36 @@ import {purchase} from './game-state.js'; -export function createPayment(){return {phase:'idle',product:null};} -export function beginPayment(state,payment,product){ - if(payment.phase!=='idle'||!product||!purchase({...state,basket:[...state.basket]},product.id))return false; - payment.product=product;payment.phase='approaching';state.busy=true;return true; + +export function createPayment() { + return {phase: 'idle', product: null}; } -export function swipePayment(state,payment){ - if(payment.phase!=='ready')return false; - state.busy=false; - if(!purchase(state,payment.product.id)){payment.phase='idle';payment.product=null;return false;} - payment.phase='approved';return true; + +export function beginPayment(state, payment, product) { + if (payment.phase !== 'idle' || !product || !purchase({ + ...state, + basket: [...state.basket] + }, product.id)) return false; + payment.product = product; + payment.phase = 'approaching'; + state.busy = true; + return true; } -export function cancelPayment(state,payment){ - if(!['approaching','ready'].includes(payment.phase))return false; - payment.phase='idle';payment.product=null;state.busy=false;return true; + +export function swipePayment(state, payment) { + if (payment.phase !== 'ready') return false; + state.busy = false; + if (!purchase(state, payment.product.id)) { + payment.phase = 'idle'; + payment.product = null; + return false; + } + payment.phase = 'approved'; + return true; +} + +export function cancelPayment(state, payment) { + if (!['approaching', 'ready'].includes(payment.phase)) return false; + payment.phase = 'idle'; + payment.product = null; + state.busy = false; + return true; } diff --git a/src/story.js b/src/story.js index 20bc86b..9c364a2 100644 --- a/src/story.js +++ b/src/story.js @@ -1,35 +1,74 @@ export const chapters = { - morning:{title:'تپهٔ آرزوها',lines:['پدر: بریم تپه؟ امروز راه رو با هم کشف می‌کنیم.']}, - rabbit:{title:'یک دوست کوچک',lines:['بچه: یه خرگوش! آروم نگاهش کنیم؟'],action:{value:'watch',label:'تماشای خرگوش'}}, - bridge:{title:'روی پل',lines:['پدر: دستم رو بگیر؛ با هم رد می‌شیم.']}, - fish:{title:'زیر پل',lines:['بچه: ماهی‌ها رو ببین!']}, - courage:{title:'اولین خرید من',lines:['پدر: حالا برای پیک‌نیک خرید کنیم.']}, - wind:{title:'باد و نقاشی',lines:['بچه: نقاشیم رفت کنار بوته!']}, - paper:{title:'پیداش کردیم',lines:['بچه: سالمه! بالای تپه کاملش می‌کنم.'],action:{value:'recover',label:'برداشتن نقاشی'}}, - fork:{title:'مسیر تپه',lines:['پدر: از راه آفتابی به تپه می‌رسیم.'],action:{value:'sunny',label:'ادامهٔ مسیر'}}, - sunny:{title:'رنگ‌های جنگل',lines:['بچه: این رنگ‌ها رو توی نقاشیم می‌کشم!']}, - shade:{title:'درخت خاطره‌ها',lines:['پدر: بچه که بودم هم زیر همین درخت می‌نشستم.']}, - summit:{title:'کنار هم',lines:['بچه: دفعهٔ بعد، من راه رو نشونت می‌دم!'],action:{value:'gold',label:'کامل کردن نقاشی'}} + morning: {title: 'تپهٔ آرزوها', lines: ['پدر: بریم تپه؟ امروز راه رو با هم کشف می‌کنیم.']}, + rabbit: { + title: 'یک دوست کوچک', + lines: ['بچه: یه خرگوش! آروم نگاهش کنیم؟'], + action: {value: 'watch', label: 'تماشای خرگوش'} + }, + bridge: {title: 'روی پل', lines: ['پدر: دستم رو بگیر؛ با هم رد می‌شیم.']}, + fish: {title: 'زیر پل', lines: ['بچه: ماهی‌ها رو ببین!']}, + courage: {title: 'اولین خرید من', lines: ['پدر: حالا برای پیک‌نیک خرید کنیم.']}, + wind: {title: 'باد و نقاشی', lines: ['بچه: نقاشیم رفت کنار بوته!']}, + paper: { + title: 'پیداش کردیم', + lines: ['بچه: سالمه! بالای تپه کاملش می‌کنم.'], + action: {value: 'recover', label: 'برداشتن نقاشی'} + }, + fork: { + title: 'مسیر تپه', + lines: ['پدر: از راه آفتابی به تپه می‌رسیم.'], + action: {value: 'sunny', label: 'ادامهٔ مسیر'} + }, + sunny: {title: 'رنگ‌های جنگل', lines: ['بچه: این رنگ‌ها رو توی نقاشیم می‌کشم!']}, + shade: {title: 'درخت خاطره‌ها', lines: ['پدر: بچه که بودم هم زیر همین درخت می‌نشستم.']}, + summit: { + title: 'کنار هم', + lines: ['بچه: دفعهٔ بعد، من راه رو نشونت می‌دم!'], + action: {value: 'gold', label: 'کامل کردن نقاشی'} + } }; -export function createStory(){return {done:[],rabbitChoice:null,route:null,sky:null};} -export function completeChapter(story,id,choice){ - if(!chapters[id]||story.done.includes(id))return false; - if(chapters[id].action&&chapters[id].action.value!==choice)return false; + +export function createStory() { + return {done: [], rabbitChoice: null, route: null, sky: null}; +} + +export function completeChapter(story, id, choice) { + if (!chapters[id] || story.done.includes(id)) return false; + if (chapters[id].action && chapters[id].action.value !== choice) return false; story.done.push(id); - if(id==='rabbit')story.rabbitChoice=choice; - if(id==='fork')story.route=choice; - if(id==='summit')story.sky=choice; + if (id === 'rabbit') story.rabbitChoice = choice; + if (id === 'fork') story.route = choice; + if (id === 'summit') story.sky = choice; return true; } -export function nextChapter(story,x){ - const done=id=>story.done.includes(id); - if(!done('morning'))return 'morning'; - for(const [id,threshold] of [['rabbit',-3.7],['bridge',-2.2],['fish',0],['courage',2.7],['wind',12],['paper',14],['fork',16]]){ - if(!done(id))return x>=threshold?id:null; + +export function nextChapter(story, x) { + const done = id => story.done.includes(id); + if (!done('morning')) return 'morning'; + for (const [id, threshold] of [['rabbit', -3.7], ['bridge', -2.2], ['fish', 0], ['courage', 2.7], ['wind', 12], ['paper', 14], ['fork', 16]]) { + if (!done(id)) return x >= threshold ? id : null; } - if(!done(story.route))return x>=19?story.route:null; + if (!done(story.route)) return x >= 19 ? story.route : null; return null; } -export function storyReady(story){return ['morning','rabbit','bridge','fish','courage','wind','paper','fork',story.route].every(id=>story.done.includes(id));} -export function routeOffset(story,x){if(x>12&&x<15&&story.done.includes('wind'))return Math.sin((x-12)/3*Math.PI)*1.5;if(x<=16||x>=21||!story.route)return 0;return Math.sin((x-16)/5*Math.PI)*(story.route==='sunny'?2.2:-2.2);} -export function discoveries(story){const entries=[];if(story.done.includes('rabbit'))entries.push(story.rabbitChoice==='sketch'?'🐇 نقاشی خرگوش':'🐇 تماشای آرام خرگوش');if(story.done.includes('fish'))entries.push('🐟 ماهی‌های زیر پل');if(story.done.includes('courage'))entries.push('🌉 پل شجاعت');if(story.done.includes('paper'))entries.push('📄 نقاشی بازیابی‌شده');if(story.done.includes('sunny'))entries.push('🦋 رنگ‌های پروانه‌ها');if(story.done.includes('shade'))entries.push('🌳 خاطرهٔ درخت قدیمی');return entries;} + +export function storyReady(story) { + return ['morning', 'rabbit', 'bridge', 'fish', 'courage', 'wind', 'paper', 'fork', story.route].every(id => story.done.includes(id)); +} + +export function routeOffset(story, x) { + if (x > 12 && x < 15 && story.done.includes('wind')) return Math.sin((x - 12) / 3 * Math.PI) * 1.5; + if (x <= 16 || x >= 21 || !story.route) return 0; + return Math.sin((x - 16) / 5 * Math.PI) * (story.route === 'sunny' ? 2.2 : -2.2); +} + +export function discoveries(story) { + const entries = []; + if (story.done.includes('rabbit')) entries.push(story.rabbitChoice === 'sketch' ? '🐇 نقاشی خرگوش' : '🐇 تماشای آرام خرگوش'); + if (story.done.includes('fish')) entries.push('🐟 ماهی‌های زیر پل'); + if (story.done.includes('courage')) entries.push('🌉 پل شجاعت'); + if (story.done.includes('paper')) entries.push('📄 نقاشی بازیابی‌شده'); + if (story.done.includes('sunny')) entries.push('🦋 رنگ‌های پروانه‌ها'); + if (story.done.includes('shade')) entries.push('🌳 خاطرهٔ درخت قدیمی'); + return entries; +} diff --git a/src/style.css b/src/style.css index dc65728..d2bae8d 100644 --- a/src/style.css +++ b/src/style.css @@ -383,23 +383,999 @@ dialog small { bottom: 85px } } -#basket{font-size:12px;margin:12px 0;color:#e6eccb;line-height:1.8}#products small{display:block;margin-top:4px;font-weight:400}#celebrate{position:absolute;bottom:16%;left:50%;transform:translateX(-50%);border:0;border-radius:30px;padding:13px 24px;background:#d6eda5;color:#233f32;white-space:nowrap}#ending button{display:block;width:100%;padding:12px;margin-top:12px;border:1px solid #b7c69a;border-radius:14px;background:#e1edc7;color:#254334}#ending-basket{font-size:32px;margin:20px 0}#badge{color:#39735b;font-weight:700}dialog{max-height:85dvh;overflow-y:auto}footer{gap:10px}footer p{margin-left:auto}body:has(#shop[open]) .touch,body:has(#ending[open]) .touch{visibility:hidden}@media(max-width:650px){.intro{top:105px}.intro h1{font-size:37px;margin:5px 0}.intro p{display:none}.mission{top:205px;bottom:auto;width:190px;left:auto;right:6%;padding:10px}.mission p{font-size:11px}#basket{margin:4px 0;font-size:11px}#speech{bottom:29%;max-width:92%;padding:10px 14px}#interact{bottom:21%}footer{flex-wrap:wrap;bottom:15px}footer p{width:100%;max-width:none;margin:0}footer button{padding:6px 12px}.touch{bottom:100px}#celebrate{bottom:20%;font-size:12px}} -#story-next{position:absolute;bottom:15%;left:50%;transform:translateX(-50%);border:1px solid #d9edb7;border-radius:30px;padding:12px 25px;background:#d6eda5;color:#233f32}.cinematic .intro,.cinematic .mission{opacity:0;pointer-events:none}.intro,.mission{transition:opacity .5s}body:has(#shop[open]) #shop{margin-right:4%;width:min(390px,42vw)}@media(max-width:650px){body:has(#shop[open]) #shop{margin: auto 4% 18px;width:92%;max-height:45dvh;padding:18px}#shop h2{font-size:23px;margin:6px 0}#products{margin:10px 0;gap:6px}#products button{padding:9px}#shop>p{margin:8px 0}#story-next{bottom:20%}} -.product-code-card{display:flex;gap:12px;align-items:center;padding:10px 14px;background:#f0f2e4;border:1px solid #dfe5d2;border-radius:12px}.product-code-card b{flex:1}.product-code-card small{display:block;color:#526d42;margin-top:3px}#code-form label{display:block;font-size:13px;margin:10px 0 7px}.code-entry{display:flex;gap:8px}.code-entry input{min-width:0;width:100%;padding:12px;border:1px solid #a5b895;border-radius:10px;font:inherit;direction:ltr;text-align:center;background:white;color:#254334}.code-entry button{flex-shrink:0;border:0;border-radius:10px;background:#365e43;color:white;padding:10px 14px}.code-entry input[aria-invalid=true]{border:2px solid #b54b3c}#shop-status{color:#86452f}@media(max-width:650px){body:has(#shop[open]) #shop{max-height:60dvh}.product-code-card{padding:6px 10px}.code-entry button{font-size:12px}} -#story-dialog{width:min(540px,92vw);padding:30px}#chapter-title{font-size:24px}#chapter-line{font-size:17px;line-height:2;min-height:100px;color:#294b3b}#chapter-count{font-size:11px;color:#7d886f}#chapter-actions{display:flex;gap:10px;flex-wrap:wrap}#chapter-actions button{flex:1;border:0;background:#365e43;color:#fff8e9;padding:13px;border-radius:12px;min-width:140px}#notebook-close{float:left;border:0;background:none;font-size:27px}#notebook canvas,#memory{width:100%;height:auto;border-radius:12px;border:5px solid #e4dbbb;margin-top:15px}#discoveries,#ending-discoveries{line-height:2;font-size:13px;color:#456347}body:has(#story-dialog[open]) .touch,body:has(#notebook[open]) .touch{visibility:hidden}footer{font-size:10px}footer button{padding:7px 10px}@media(max-width:650px){#chapter-line{font-size:15px;min-height:90px}#story-dialog{padding:23px}footer{gap:6px}footer button{font-size:10px}.touch{bottom:112px}} -#chapter-count{direction:ltr;text-align:right} + +#basket { + font-size: 12px; + margin: 12px 0; + color: #e6eccb; + line-height: 1.8 +} + +#products small { + display: block; + margin-top: 4px; + font-weight: 400 +} + +#celebrate { + position: absolute; + bottom: 16%; + left: 50%; + transform: translateX(-50%); + border: 0; + border-radius: 30px; + padding: 13px 24px; + background: #d6eda5; + color: #233f32; + white-space: nowrap +} + +#ending button { + display: block; + width: 100%; + padding: 12px; + margin-top: 12px; + border: 1px solid #b7c69a; + border-radius: 14px; + background: #e1edc7; + color: #254334 +} + +#ending-basket { + font-size: 32px; + margin: 20px 0 +} + +#badge { + color: #39735b; + font-weight: 700 +} + +dialog { + max-height: 85dvh; + overflow-y: auto +} + +footer { + gap: 10px +} + +footer p { + margin-left: auto +} + +body:has(#shop[open]) .touch, body:has(#ending[open]) .touch { + visibility: hidden +} + +@media (max-width: 650px) { + .intro { + top: 105px + } + + .intro h1 { + font-size: 37px; + margin: 5px 0 + } + + .intro p { + display: none + } + + .mission { + top: 205px; + bottom: auto; + width: 190px; + left: auto; + right: 6%; + padding: 10px + } + + .mission p { + font-size: 11px + } + + #basket { + margin: 4px 0; + font-size: 11px + } + + #speech { + bottom: 29%; + max-width: 92%; + padding: 10px 14px + } + + #interact { + bottom: 21% + } + + footer { + flex-wrap: wrap; + bottom: 15px + } + + footer p { + width: 100%; + max-width: none; + margin: 0 + } + + footer button { + padding: 6px 12px + } + + .touch { + bottom: 100px + } + + #celebrate { + bottom: 20%; + font-size: 12px + } +} + +#story-next { + position: absolute; + bottom: 15%; + left: 50%; + transform: translateX(-50%); + border: 1px solid #d9edb7; + border-radius: 30px; + padding: 12px 25px; + background: #d6eda5; + color: #233f32 +} + +.cinematic .intro, .cinematic .mission { + opacity: 0; + pointer-events: none +} + +.intro, .mission { + transition: opacity .5s +} + +body:has(#shop[open]) #shop { + margin-right: 4%; + width: min(390px, 42vw) +} + +@media (max-width: 650px) { + body:has(#shop[open]) #shop { + margin: auto 4% 18px; + width: 92%; + max-height: 45dvh; + padding: 18px + } + + #shop h2 { + font-size: 23px; + margin: 6px 0 + } + + #products { + margin: 10px 0; + gap: 6px + } + + #products button { + padding: 9px + } + + #shop > p { + margin: 8px 0 + } + + #story-next { + bottom: 20% + } +} + +.product-code-card { + display: flex; + gap: 12px; + align-items: center; + padding: 10px 14px; + background: #f0f2e4; + border: 1px solid #dfe5d2; + border-radius: 12px +} + +.product-code-card b { + flex: 1 +} + +.product-code-card small { + display: block; + color: #526d42; + margin-top: 3px +} + +#code-form label { + display: block; + font-size: 13px; + margin: 10px 0 7px +} + +.code-entry { + display: flex; + gap: 8px +} + +.code-entry input { + min-width: 0; + width: 100%; + padding: 12px; + border: 1px solid #a5b895; + border-radius: 10px; + font: inherit; + direction: ltr; + text-align: center; + background: white; + color: #254334 +} + +.code-entry button { + flex-shrink: 0; + border: 0; + border-radius: 10px; + background: #365e43; + color: white; + padding: 10px 14px +} + +.code-entry input[aria-invalid=true] { + border: 2px solid #b54b3c +} + +#shop-status { + color: #86452f +} + +@media (max-width: 650px) { + body:has(#shop[open]) #shop { + max-height: 60dvh + } + + .product-code-card { + padding: 6px 10px + } + + .code-entry button { + font-size: 12px + } +} + +#story-dialog { + width: min(540px, 92vw); + padding: 30px +} + +#chapter-title { + font-size: 24px +} + +#chapter-line { + font-size: 17px; + line-height: 2; + min-height: 100px; + color: #294b3b +} + +#chapter-count { + font-size: 11px; + color: #7d886f +} + +#chapter-actions { + display: flex; + gap: 10px; + flex-wrap: wrap +} + +#chapter-actions button { + flex: 1; + border: 0; + background: #365e43; + color: #fff8e9; + padding: 13px; + border-radius: 12px; + min-width: 140px +} + +#notebook-close { + float: left; + border: 0; + background: none; + font-size: 27px +} + +#notebook canvas, #memory { + width: 100%; + height: auto; + border-radius: 12px; + border: 5px solid #e4dbbb; + margin-top: 15px +} + +#discoveries, #ending-discoveries { + line-height: 2; + font-size: 13px; + color: #456347 +} + +body:has(#story-dialog[open]) .touch, body:has(#notebook[open]) .touch { + visibility: hidden +} + +footer { + font-size: 10px +} + +footer button { + padding: 7px 10px +} + +@media (max-width: 650px) { + #chapter-line { + font-size: 15px; + min-height: 90px + } + + #story-dialog { + padding: 23px + } + + footer { + gap: 6px + } + + footer button { + font-size: 10px + } + + .touch { + bottom: 112px + } +} + +#chapter-count { + direction: ltr; + text-align: right +} + /* Story text stays beside the scene and never captures focus or blocks walking. */ -#story-dialog{position:fixed;top:36%;right:4%;width:min(340px,32vw);max-height:48dvh;overflow:auto;margin:0;padding:20px;border:1px solid #e8e6d080;border-radius:18px;background:#fff9e9ed;color:#294b3b;box-shadow:0 10px 35px #17372d30;z-index:2;backdrop-filter:blur(10px)}#story-dialog[hidden]{display:none}#story-dialog .eyebrow{color:#6d8055}#chapter-title{font-size:17px;margin:8px 0}#chapter-line{font-size:14px;line-height:1.9;min-height:0;margin:12px 0}#chapter-count{margin:0;font-size:10px}#chapter-actions:empty{display:none}#chapter-actions button{min-width:100px;font-size:12px;padding:10px}body:has(#story-dialog:not([hidden])) #speech{visibility:hidden}@media(max-width:650px){#story-dialog{top:auto;bottom:27%;right:4%;width:min(310px,85vw);max-height:34dvh;padding:14px}#chapter-title{font-size:15px}#chapter-line{font-size:12px;margin:7px 0}body:has(#story-dialog:not([hidden])) .mission{opacity:0;pointer-events:none}} -#controls-guide{position:fixed;left:5%;top:350px;width:230px;padding:15px 18px;border:1px solid #ffffff30;border-radius:16px;background:#173b2ad9;backdrop-filter:blur(8px);font-size:12px}#controls-guide strong{font-size:13px;color:#e1eebe}#controls-guide p{margin:9px 0}#controls-guide kbd{display:inline-block;min-width:23px;text-align:center;padding:2px 5px;border:1px solid #ffffff50;border-radius:5px;font:inherit;background:#ffffff12}#controls-guide small{font-size:10px;color:#d5e0c3}.touch{display:grid;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(2,46px);gap:7px;bottom:85px;left:5%}.touch button{width:46px;height:46px;font-size:23px}#zoom-in{grid-column:2;grid-row:1}#back{grid-column:1;grid-row:2}#zoom-out{grid-column:2;grid-row:2}#forward{grid-column:3;grid-row:2}#products{max-height:29dvh;overflow-y:auto;overscroll-behavior:contain;padding:2px}#code-form{position:relative}#shop{overscroll-behavior:contain}@media(max-width:650px){#controls-guide{top:100px;left:4%;width:155px;padding:10px;font-size:10px}#controls-guide p{margin:6px 0}#controls-guide strong{font-size:11px}#controls-guide small{font-size:9px}.intro{right:4%;max-width:47%}.intro h1{font-size:29px}.touch{bottom:105px;grid-template-columns:repeat(3,42px);grid-template-rows:repeat(2,42px)}.touch button{width:42px;height:42px}#products{max-height:20dvh}body:has(#shop[open]) #shop{max-height:70dvh}} -@media(max-height:650px) and (min-width:651px){#controls-guide{top:110px}.mission{display:none}.touch{bottom:65px}} +#story-dialog { + position: fixed; + top: 36%; + right: 4%; + width: min(340px, 32vw); + max-height: 48dvh; + overflow: auto; + margin: 0; + padding: 20px; + border: 1px solid #e8e6d080; + border-radius: 18px; + background: #fff9e9ed; + color: #294b3b; + box-shadow: 0 10px 35px #17372d30; + z-index: 2; + backdrop-filter: blur(10px) +} + +#story-dialog[hidden] { + display: none +} + +#story-dialog .eyebrow { + color: #6d8055 +} + +#chapter-title { + font-size: 17px; + margin: 8px 0 +} + +#chapter-line { + font-size: 14px; + line-height: 1.9; + min-height: 0; + margin: 12px 0 +} + +#chapter-count { + margin: 0; + font-size: 10px +} + +#chapter-actions:empty { + display: none +} + +#chapter-actions button { + min-width: 100px; + font-size: 12px; + padding: 10px +} + +body:has(#story-dialog:not([hidden])) #speech { + visibility: hidden +} + +@media (max-width: 650px) { + #story-dialog { + top: auto; + bottom: 27%; + right: 4%; + width: min(310px, 85vw); + max-height: 34dvh; + padding: 14px + } + + #chapter-title { + font-size: 15px + } + + #chapter-line { + font-size: 12px; + margin: 7px 0 + } + + body:has(#story-dialog:not([hidden])) .mission { + opacity: 0; + pointer-events: none + } +} + +#controls-guide { + position: fixed; + left: 5%; + top: 350px; + width: 230px; + padding: 15px 18px; + border: 1px solid #ffffff30; + border-radius: 16px; + background: #173b2ad9; + backdrop-filter: blur(8px); + font-size: 12px +} + +#controls-guide strong { + font-size: 13px; + color: #e1eebe +} + +#controls-guide p { + margin: 9px 0 +} + +#controls-guide kbd { + display: inline-block; + min-width: 23px; + text-align: center; + padding: 2px 5px; + border: 1px solid #ffffff50; + border-radius: 5px; + font: inherit; + background: #ffffff12 +} + +#controls-guide small { + font-size: 10px; + color: #d5e0c3 +} + +.touch { + display: grid; + grid-template-columns:repeat(3, 46px); + grid-template-rows:repeat(2, 46px); + gap: 7px; + bottom: 85px; + left: 5% +} + +.touch button { + width: 46px; + height: 46px; + font-size: 23px +} + +#zoom-in { + grid-column: 2; + grid-row: 1 +} + +#back { + grid-column: 1; + grid-row: 2 +} + +#zoom-out { + grid-column: 2; + grid-row: 2 +} + +#forward { + grid-column: 3; + grid-row: 2 +} + +#products { + max-height: 29dvh; + overflow-y: auto; + overscroll-behavior: contain; + padding: 2px +} + +#code-form { + position: relative +} + +#shop { + overscroll-behavior: contain +} + +@media (max-width: 650px) { + #controls-guide { + top: 100px; + left: 4%; + width: 155px; + padding: 10px; + font-size: 10px + } + + #controls-guide p { + margin: 6px 0 + } + + #controls-guide strong { + font-size: 11px + } + + #controls-guide small { + font-size: 9px + } + + .intro { + right: 4%; + max-width: 47% + } + + .intro h1 { + font-size: 29px + } + + .touch { + bottom: 105px; + grid-template-columns:repeat(3, 42px); + grid-template-rows:repeat(2, 42px) + } + + .touch button { + width: 42px; + height: 42px + } + + #products { + max-height: 20dvh + } + + body:has(#shop[open]) #shop { + max-height: 70dvh + } +} + +@media (max-height: 650px) and (min-width: 651px) { + #controls-guide { + top: 110px + } + + .mission { + display: none + } + + .touch { + bottom: 65px + } +} /* White story card; automatic dialogue stays outside a modal. */ -#story-dialog{background:#fffdf5;color:#294b3b;text-align:right}#chapter-line{color:#294b3b;text-shadow:none}#chapter-title{color:#436044}#story-dialog>.eyebrow{display:block}#chapter-count{display:block} +#story-dialog { + background: #fffdf5; + color: #294b3b; + text-align: right +} + +#chapter-line { + color: #294b3b; + text-shadow: none +} + +#chapter-title { + color: #436044 +} + +#story-dialog > .eyebrow { + display: block +} + +#chapter-count { + display: block +} + /* Dialogue occupies only the lower part of the scene. */ -#story-dialog{top:auto;bottom:19%;right:auto;left:50%;transform:translateX(-50%);width:min(660px,86vw);max-height:30dvh;padding:13px 20px;text-align:center}#chapter-line{margin:5px 0}#chapter-title{font-size:12px}#story-dialog>.eyebrow,#chapter-count{display:none}#speech{bottom:19%}#shop,body:has(#shop[open]) #shop{width:min(860px,94vw);margin:auto;max-height:90dvh;padding:24px}.currency-note{padding:10px 14px;border-radius:12px;background:#e9efda;line-height:1.8;color:#365039!important;font-size:12px!important}.vending-browser{display:grid;grid-template-columns:minmax(180px,.8fr) minmax(260px,1.2fr);gap:16px;direction:rtl}.vending-browser h3{font-size:13px;margin:8px 0}#channels{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;max-height:34dvh;overflow:auto;align-content:start;direction:ltr;padding:3px}#channels button{font:inherit;padding:8px 0;border:1px solid #bccaa8;background:#e8eedb;color:#244531;border-radius:7px}#channels button:disabled{opacity:.35;cursor:not-allowed}#channels button.selected,#products button.selected{background:#cce5af;border:2px solid #3f764c}#products{max-height:34dvh;margin:0;padding:3px}#products button{width:100%;margin-bottom:6px}#products .icon{font-size:23px}.product-code-card{font-size:12px}#code-form{margin-top:14px}.wallet{white-space:nowrap}@media(max-width:650px){#story-dialog{top:auto;bottom:25%;right:auto;left:50%;width:92vw;padding:10px 14px}body:has(#shop[open]) #shop{width:96vw;max-height:92dvh;margin:auto;padding:18px 12px}.vending-browser{grid-template-columns:minmax(100px,.65fr) minmax(170px,1.35fr);gap:8px}#channels{grid-template-columns:repeat(3,1fr);max-height:30dvh;gap:4px}#channels button{padding:7px 0;font-size:12px}#products{max-height:30dvh}.product-code-card{gap:5px;padding:8px!important;font-size:10px}#products .icon{display:none}.currency-note{font-size:10px!important}.vending-browser h3{font-size:11px}} -#machine-note{position:fixed;right:4%;top:33%;padding:14px 18px;background:#fffdf2ed;color:#2b4b37;border-radius:14px;font-size:13px;line-height:1.9;max-width:250px}#machine-note[hidden]{display:none}#machine-note small{font-size:11px}@media(max-width:650px){#machine-note{top:auto;bottom:43%;right:4%;font-size:11px;padding:10px;max-width:210px}} -#payment-panel{position:fixed;bottom:7%;right:4%;z-index:5;width:min(350px,92vw);border-radius:18px;padding:20px;background:#fffdf2;color:#294b3b;box-shadow:0 10px 50px #0003}#payment-panel[hidden]{display:none}#payment-panel h2{font-size:18px;margin:0 0 12px}#payment-panel p{font-size:13px;line-height:1.8}#payment-panel button{padding:11px 16px;border:0;border-radius:10px;background:#315e43;color:white;margin:5px}#payment-panel button:disabled{opacity:.5;cursor:wait}#payment-panel small{display:block;margin-top:10px;font-size:10px}body:has(#payment-panel:not([hidden])) #story-dialog,body:has(#payment-panel:not([hidden])) #speech,body:has(#payment-panel:not([hidden])) #machine-note{visibility:hidden}body:has(#payment-panel:not([hidden])) .touch{visibility:hidden} +#story-dialog { + top: auto; + bottom: 19%; + right: auto; + left: 50%; + transform: translateX(-50%); + width: min(660px, 86vw); + max-height: 30dvh; + padding: 13px 20px; + text-align: center +} + +#chapter-line { + margin: 5px 0 +} + +#chapter-title { + font-size: 12px +} + +#story-dialog > .eyebrow, #chapter-count { + display: none +} + +#speech { + bottom: 19% +} + +#shop, body:has(#shop[open]) #shop { + width: min(860px, 94vw); + margin: auto; + max-height: 90dvh; + padding: 24px +} + +.currency-note { + padding: 10px 14px; + border-radius: 12px; + background: #e9efda; + line-height: 1.8; + color: #365039 !important; + font-size: 12px !important +} + +.vending-browser { + display: grid; + grid-template-columns:minmax(180px, .8fr) minmax(260px, 1.2fr); + gap: 16px; + direction: rtl +} + +.vending-browser h3 { + font-size: 13px; + margin: 8px 0 +} + +#channels { + display: grid; + grid-template-columns:repeat(5, 1fr); + gap: 6px; + max-height: 34dvh; + overflow: auto; + align-content: start; + direction: ltr; + padding: 3px +} + +#channels button { + font: inherit; + padding: 8px 0; + border: 1px solid #bccaa8; + background: #e8eedb; + color: #244531; + border-radius: 7px +} + +#channels button:disabled { + opacity: .35; + cursor: not-allowed +} + +#channels button.selected, #products button.selected { + background: #cce5af; + border: 2px solid #3f764c +} + +#products { + max-height: 34dvh; + margin: 0; + padding: 3px +} + +#products button { + width: 100%; + margin-bottom: 6px +} + +#products .icon { + font-size: 23px +} + +.product-code-card { + font-size: 12px +} + +#code-form { + margin-top: 14px +} + +.wallet { + white-space: nowrap +} + +@media (max-width: 650px) { + #story-dialog { + top: auto; + bottom: 25%; + right: auto; + left: 50%; + width: 92vw; + padding: 10px 14px + } + + body:has(#shop[open]) #shop { + width: 96vw; + max-height: 92dvh; + margin: auto; + padding: 18px 12px + } + + .vending-browser { + grid-template-columns:minmax(100px, .65fr) minmax(170px, 1.35fr); + gap: 8px + } + + #channels { + grid-template-columns:repeat(3, 1fr); + max-height: 30dvh; + gap: 4px + } + + #channels button { + padding: 7px 0; + font-size: 12px + } + + #products { + max-height: 30dvh + } + + .product-code-card { + gap: 5px; + padding: 8px !important; + font-size: 10px + } + + #products .icon { + display: none + } + + .currency-note { + font-size: 10px !important + } + + .vending-browser h3 { + font-size: 11px + } +} + +#machine-note { + position: fixed; + right: 4%; + top: 33%; + padding: 14px 18px; + background: #fffdf2ed; + color: #2b4b37; + border-radius: 14px; + font-size: 13px; + line-height: 1.9; + max-width: 250px +} + +#machine-note[hidden] { + display: none +} + +#machine-note small { + font-size: 11px +} + +@media (max-width: 650px) { + #machine-note { + top: auto; + bottom: 43%; + right: 4%; + font-size: 11px; + padding: 10px; + max-width: 210px + } +} + +#payment-panel { + position: fixed; + bottom: 7%; + right: 4%; + z-index: 5; + width: min(350px, 92vw); + border-radius: 18px; + padding: 20px; + background: #fffdf2; + color: #294b3b; + box-shadow: 0 10px 50px #0003 +} + +#payment-panel[hidden] { + display: none +} + +#payment-panel h2 { + font-size: 18px; + margin: 0 0 12px +} + +#payment-panel p { + font-size: 13px; + line-height: 1.8 +} + +#payment-panel button { + padding: 11px 16px; + border: 0; + border-radius: 10px; + background: #315e43; + color: white; + margin: 5px +} + +#payment-panel button:disabled { + opacity: .5; + cursor: wait +} + +#payment-panel small { + display: block; + margin-top: 10px; + font-size: 10px +} + +body:has(#payment-panel:not([hidden])) #story-dialog, body:has(#payment-panel:not([hidden])) #speech, body:has(#payment-panel:not([hidden])) #machine-note { + visibility: hidden +} + +body:has(#payment-panel:not([hidden])) .touch { + visibility: hidden +} + /* Contextual third-person interaction prompt, anchored to the reader in 3D. */ -#interact{position:fixed;bottom:auto;transform:translate(-50%,-50%);display:flex;align-items:center;gap:9px;padding:7px 12px 7px 7px;border:1px solid #fff9;border-radius:32px;background:#152c27bb;color:#fff9e9;backdrop-filter:blur(10px);box-shadow:0 3px 20px #0004;z-index:3;min-height:48px}#interact[hidden]{display:none}#interact .interact-key{display:grid;place-items:center;margin:0;width:34px;height:34px;border:2px solid #f2e0af;border-radius:50%;background:#fff0;color:#fff9e9;font-size:18px;font-weight:700}#interact b{font-size:12px;font-weight:500}.near-machine .intro{opacity:0;pointer-events:none} +#interact { + position: fixed; + bottom: auto; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + gap: 9px; + padding: 7px 12px 7px 7px; + border: 1px solid #fff9; + border-radius: 32px; + background: #152c27bb; + color: #fff9e9; + backdrop-filter: blur(10px); + box-shadow: 0 3px 20px #0004; + z-index: 3; + min-height: 48px +} + +#interact[hidden] { + display: none +} + +#interact .interact-key { + display: grid; + place-items: center; + margin: 0; + width: 34px; + height: 34px; + border: 2px solid #f2e0af; + border-radius: 50%; + background: #fff0; + color: #fff9e9; + font-size: 18px; + font-weight: 700 +} + +#interact b { + font-size: 12px; + font-weight: 500 +} + +.near-machine .intro { + opacity: 0; + pointer-events: none +} + /* Frosted glass panels retain the forest behind them. */ -dialog,#payment-panel{background:linear-gradient(135deg,#ffffffc9,#e5f0dd9c);border:1px solid #fff9;backdrop-filter:blur(22px) saturate(140%);-webkit-backdrop-filter:blur(22px) saturate(140%);box-shadow:0 24px 80px #071e2440,inset 0 1px 0 #fffd;color:#173b2c}dialog::backdrop{background:#071d2340;backdrop-filter:blur(4px)}dialog p,dialog small{color:#284c3b}.currency-note{background:#ffffff50;border:1px solid #fff8}#channels button,#products button{background:#ffffff50;border-color:#ffffffa0;color:#153a2b}#channels button.selected,#products button.selected{background:#b9dca5b0;border-color:#41714e}#code-form input{background:#fff9;border-color:#fff;color:#153a2b}#payment-panel small{color:#284c3b}#notebook canvas,#memory{border-color:#fff6}#ending button{background:#e7f3d68c;border-color:#fff8}#machine-note{background:#f8fff0a6;border:1px solid #fffa;backdrop-filter:blur(14px)}@media(max-width:650px){#interact{bottom:auto;padding:6px 10px 6px 6px}#interact .interact-key{width:32px;height:32px}} +dialog, #payment-panel { + background: linear-gradient(135deg, #ffffffc9, #e5f0dd9c); + border: 1px solid #fff9; + backdrop-filter: blur(22px) saturate(140%); + -webkit-backdrop-filter: blur(22px) saturate(140%); + box-shadow: 0 24px 80px #071e2440, inset 0 1px 0 #fffd; + color: #173b2c +} + +dialog::backdrop { + background: #071d2340; + backdrop-filter: blur(4px) +} + +dialog p, dialog small { + color: #284c3b +} + +.currency-note { + background: #ffffff50; + border: 1px solid #fff8 +} + +#channels button, #products button { + background: #ffffff50; + border-color: #ffffffa0; + color: #153a2b +} + +#channels button.selected, #products button.selected { + background: #b9dca5b0; + border-color: #41714e +} + +#code-form input { + background: #fff9; + border-color: #fff; + color: #153a2b +} + +#payment-panel small { + color: #284c3b +} + +#notebook canvas, #memory { + border-color: #fff6 +} + +#ending button { + background: #e7f3d68c; + border-color: #fff8 +} + +#machine-note { + background: #f8fff0a6; + border: 1px solid #fffa; + backdrop-filter: blur(14px) +} + +@media (max-width: 650px) { + #interact { + bottom: auto; + padding: 6px 10px 6px 6px + } + + #interact .interact-key { + width: 32px; + height: 32px + } +}