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
+ }
+}