This commit is contained in:
2026-10-20 12:07:26 +03:30
parent f7c6aee286
commit 430d7e1c11
8 changed files with 1848 additions and 338 deletions
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+6 -3
View File
@@ -46,7 +46,8 @@ export function createFamily() {
shape(torso, new THREE.CylinderGeometry(.075 * scale, .09 * scale, .18 * scale, 12), skin, [0, 1.59 * scale, 0]); 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]); 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]) { 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 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(); const head = new THREE.Group();
head.position.y = 1.77 * scale; head.position.y = 1.77 * scale;
@@ -59,8 +60,10 @@ export function createFamily() {
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
sphere(head, .06 * scale, skin, [side * .26 * scale, 0, 0]); 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]); 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 eye = sphere(head, .022 * scale, shoes, [side * .09 * scale, .03 * scale, .258 * scale], [.8, 1, .45]);
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; 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]); 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]); const smile = shape(head, new THREE.TorusGeometry(.068 * scale, .012 * scale, 6, 14, Math.PI), shoes, [0, -.075 * scale, .252 * scale]);
+1
View File
@@ -3,6 +3,7 @@ export function movementDirection(pressed, held) {
return Number(pressed.has('arrowright') || pressed.has('d') || held.has(1)) return Number(pressed.has('arrowright') || pressed.has('d') || held.has(1))
- Number(pressed.has('arrowleft') || pressed.has('a') || 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; return !shopOpen && !notebookOpen && !endingOpen && !busy && !finished;
} }
+9 -1
View File
@@ -17,7 +17,15 @@ const assortment = [
// All 60 physical channels exist; only 21–60 are available in this scene. // All 60 physical channels exist; only 21–60 are available in this scene.
export const channels = Array.from({length: 60}, (_, index) => { export const channels = Array.from({length: 60}, (_, index) => {
const channel = index + 1, base = assortment[(channel - 22 + 63) % assortment.length]; 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}; 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);
+560 -103
View File
@@ -73,6 +73,7 @@ function random() {
} }
const trees = []; const trees = [];
function tree(x, z, s) { function tree(x, z, s) {
const g = new THREE.Group(); const g = new THREE.Group();
g.position.set(x, 0, z); g.position.set(x, 0, z);
@@ -118,23 +119,43 @@ box(1.13, 1.72, .1, material('#223f3b'), machine, [-.13, 1.55, .65]);
const bottleMats = ['#e5ae47', '#badd81', '#c8704e'].map(c => material(c)); const bottleMats = ['#e5ae47', '#badd81', '#c8704e'].map(c => material(c));
const steel = material('#aeb8b6', {metalness: .8, roughness: .3}); const steel = material('#aeb8b6', {metalness: .8, roughness: .3});
for (let row = 0; row < 6; row++) { for (let row = 0; row < 6; row++) {
const shelfY=.86+row*.275;box(1.17,.025,.28,steel,machine,[-.13,shelfY,.73]); const shelfY = .86 + row * .275;
box(1.17, .025, .28, steel, machine, [-.13, shelfY, .73]);
for (let col = 0; col < 10; col++) { for (let col = 0; col < 10; col++) {
const channel = (5 - row) * 10 + col + 1, px = -.65 + col * .115; 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]);} if (channel % 3) {
else box(.068,.14,.07,bottleMats[channel%3],machine,[px,shelfY+.09,.73]); 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. // Each slot has its own dispensing spiral.
const spiral = mesh(new THREE.TorusGeometry(.035, .006, 4, 10), steel, machine, [px, shelfY + .045, .79]); 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 slots = document.createElement('canvas');
const slotContext=slots.getContext('2d');slotContext.textAlign='center';slotContext.font='bold 25px sans-serif'; 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++) { 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 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]); 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]); 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]); 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]); 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 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 (let i = 0; i < 6; i++) box(.24, .014, .025, steel, machine, [.67, .55 + i * .045, .58]);
@@ -156,117 +177,344 @@ const sign = material('#ffffff', {map: new THREE.CanvasTexture(label), emissive:
box(1.5, .38, .05, sign, machine, [0, 2.68, .58]); box(1.5, .38, .05, sign, machine, [0, 2.68, .58]);
// A separate card, animated through the reader only after an explicit swipe. // 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]); 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; 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 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 delivery = new THREE.Group();
scene.add(delivery);
const deliveryBody = mesh(new THREE.CylinderGeometry(.1, .1, .28, 10), bottleMats[0], delivery); const deliveryBody = mesh(new THREE.CylinderGeometry(.1, .1, .28, 10), bottleMats[0], delivery);
box(.09, .05, .09, dark, delivery, [0, .16, 0]); box(.09, .05, .09, dark, delivery, [0, .16, 0]);
delivery.visible = false; delivery.visible = false;
// Picnic clearing, checked blanket, basket and a recycling bin. // 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); 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]); 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); const picnicFood = new THREE.Group();
picnic.add(picnicFood);
box(.5, .3, .35, material('#ac8252'), picnic, [-1, .25, -.8]); 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]); 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]); 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]); 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); 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]); 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 glow = new THREE.PointLight('#a8ffc5', 2, 7);
const butterfly=new THREE.Group();scene.add(butterfly); glow.position.set(9, 2, stationZ + 1);
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;}); 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. // A raised footbridge crosses a shallow stream before the vending stop.
const bridgeX = 0; 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 water = mesh(new THREE.PlaneGeometry(3.4, 40), material('#68b4b6', {
const bridge=new THREE.Group();scene.add(bridge); 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'); 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 (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 side of [-1, 1]) {
for (const bx of [-2, 0, 2]) box(.12, 1, .12, bark, bridge, [bx, .65, pathZ(bx) + side * 1.3]); 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 rail = box(4.3, .1, .1, wood, bridge, [0, 1.08, side * 1.3]);
rail.rotation.y = -.25;
} }
function pathHeight(at){return Math.max(0,Math.min(1,(2.6-Math.abs(at))/.6))*.3+THREE.MathUtils.smoothstep(at,17,22)*1.8;}
const rabbit=new THREE.Group();scene.add(rabbit); 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 fur = material('#eee2ca'), pink = material('#d5a193');
const rabbitBody=ball(.28,fur,rabbit,[0,.3,0]);rabbitBody.scale.set(1.3,1,1); const rabbitBody = ball(.28, fur, rabbit, [0, .3, 0]);
ball(.22,fur,rabbit,[.24,.55,0]);ball(.12,fur,rabbit,[-.35,.4,0]); rabbitBody.scale.set(1.3, 1, 1);
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]);} ball(.22, fur, rabbit, [.24, .55, 0]);
const lostPaper=box(.6,.025,.43,material('#fff3d5'),scene,[14,.35,Math.sin(14*.17)*1.5+1.5]);lostPaper.rotation.z=.15; 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 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 oldTree = new THREE.Group();
const hill=mesh(new THREE.SphereGeometry(5,24,16),green,scene,[23,-1.3,0]);hill.scale.set(1,.65,1.3); 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; let branchPath = null;
function rebuildRoute() { function rebuildRoute() {
if(branchPath){scene.remove(branchPath);branchPath.geometry.dispose();branchPath.material.dispose();} if (branchPath) {
const pts=[];for(let at=11;at<=24;at+=.15)pts.push(new THREE.Vector3(at,pathHeight(at)+.045,pathZ(at))); 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')); 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. // Flatten only the cross-section, retaining the hill's elevation.
const positions = branchPath.geometry.attributes.position; 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);} for (let i = 0; i < positions.count; i++) {
positions.needsUpdate=true;branchPath.geometry.computeVertexNormals(); const px = positions.getX(i);
positions.setY(i, pathHeight(px) + .045);
} }
positions.needsUpdate = true;
branchPath.geometry.computeVertexNormals();
}
rebuildRoute(); 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);} const fishes = [];
for (let i = 0; i < 4; i++) {
const fish = ball(.09, material('#e8b365'), scene, [.4, .11, -.6 + i * .4]);
fish.scale.set(2, .5, 1);
fishes.push(fish);
}
let encounterTime = 0, rabbitSeen = false, encounterActive = false, facing = Math.PI / 2; let encounterTime = 0, rabbitSeen = false, encounterActive = false, facing = Math.PI / 2;
const $ = s => document.querySelector(s), shop = $('#shop'), ending = $('#ending'); const $ = s => document.querySelector(s), shop = $('#shop'), ending = $('#ending');
const storyDialog = $('#story-dialog'), notebook = $('#notebook'); const storyDialog = $('#story-dialog'), notebook = $('#notebook');
function renderChapter() { 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 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; 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);} 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); chapterTimer = Math.max(2.5, chapter.lines[chapterLine].length * .055);
} }
function performStoryAction() { function performStoryAction() {
if (!chapterId || shop.open || notebook.open || ending.open || state.busy) return; if (!chapterId || shop.open || notebook.open || ending.open || state.busy) return;
const chapter = chapters[chapterId]; const chapter = chapters[chapterId];
if (chapter.action && chapterLine === chapter.lines.length - 1) finishChapter(chapter.action.value); if (chapter.action && chapterLine === chapter.lines.length - 1) finishChapter(chapter.action.value);
} }
function showChapter(id){chapterId=id;chapterLine=0;storyDialog.hidden=false;renderChapter();}
function showChapter(id) {
chapterId = id;
chapterLine = 0;
storyDialog.hidden = false;
renderChapter();
}
function finishChapter(choice) { function finishChapter(choice) {
const id=chapterId;if(!completeChapter(story,id,choice))return; const id = chapterId;
if(id==='rabbit'){rabbitSeen=true;encounterTime=0;} if (!completeChapter(story, id, choice)) return;
if (id === 'rabbit') {
rabbitSeen = true;
encounterTime = 0;
}
if (id === 'fork' || id === 'wind') rebuildRoute(); if (id === 'fork' || id === 'wind') rebuildRoute();
storyDialog.hidden=true;chapterId=null;syncHUD(); storyDialog.hidden = true;
if(id==='summit'){drawMemory($('#memory'));$('#ending-discoveries').textContent=discoveries(story).join(' · ');ending.showModal();} chapterId = null;
syncHUD();
if (id === 'summit') {
drawMemory($('#memory'));
$('#ending-discoveries').textContent = discoveries(story).join(' · ');
ending.showModal();
}
} }
function drawMemory(canvas) { 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(); const c = canvas.getContext('2d');
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.clearRect(0, 0, 600, 360);
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(); c.fillStyle = story.sky === 'gold' ? '#f5d18b' : story.sky === 'rose' ? '#eeb9ba' : '#b9dfeb';
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();} c.fillRect(0, 0, 600, 360);
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); 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();
} }
$('#notebook-open').onclick=()=>{clearInput();$('#discoveries').textContent=discoveries(story).join(' · ')||'هنوز اول راهیم؛ کشف‌های امروز اینجا ثبت می‌شن.';drawMemory($('#drawing'));notebook.showModal();};$('#notebook-close').onclick=()=>notebook.close();notebook.addEventListener('close',clearInput); c.strokeStyle = '#8a674d';
c.lineWidth = 8;
c.beginPath();
c.moveTo(75, 278);
c.lineTo(210, 278);
c.moveTo(85, 263);
c.lineTo(200, 263);
c.stroke();
function person(px, py, size, color) {
c.fillStyle = '#edbb94';
c.beginPath();
c.arc(px, py, size * .18, 0, Math.PI * 2);
c.fill();
c.strokeStyle = color;
c.lineWidth = size * .16;
c.beginPath();
c.moveTo(px, py + size * .22);
c.lineTo(px, py + size * .65);
c.moveTo(px, py + size * .3);
c.lineTo(px + size * .3, py + size * .48);
c.moveTo(px, py + size * .65);
c.lineTo(px - size * .15, py + size);
c.moveTo(px, py + size * .65);
c.lineTo(px + size * .15, py + size);
c.stroke();
}
person(300, 200, 90, '#b28b48');
person(347, 235, 58, '#c56f4d');
c.strokeStyle = '#d4a77d';
c.lineWidth = 5;
c.beginPath();
c.moveTo(327, 243);
c.lineTo(347, 252);
c.stroke();
c.font = '20px Tahoma';
c.textAlign = 'center';
c.fillStyle = '#294b3c';
c.fillText(story.sky ? 'من و بابا، تپهٔ آرزوها' : 'من و بابا… ادامه دارد', 300, 338);
}
$('#notebook-open').onclick = () => {
clearInput();
$('#discoveries').textContent = discoveries(story).join(' · ') || 'هنوز اول راهیم؛ کشف‌های امروز اینجا ثبت می‌شن.';
drawMemory($('#drawing'));
notebook.showModal();
};
$('#notebook-close').onclick = () => notebook.close();
notebook.addEventListener('close', clearInput);
let payment = createPayment(), paymentTime = 0; let payment = createPayment(), paymentTime = 0;
let state = createState(), x = -8, moving = 0, gait = 0, deliveryTime = 0, activeProduct = null, elapsed = 0; let state = createState(), x = -8, moving = 0, gait = 0, deliveryTime = 0, activeProduct = null, elapsed = 0;
const pressed = new Set(), held = new Set(); const pressed = new Set(), held = new Set();
const fa = n => n.toLocaleString('fa-IR'); const fa = n => n.toLocaleString('fa-IR');
let audio = null, soundEnabled = false, nextBird = 0, zoomDistance = 1; 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 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() { function syncHUD() {
$('#balance').textContent = fa(state.balance); $('#balance').textContent = fa(state.balance);
$('#basket').textContent = state.basket.length ? '🧺 ' + state.basket.map(p => p.icon).join(' ') : '🧺 سبد پیک‌نیک خالی است'; $('#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') ? 'یک خوراکی هم برای پیک‌نیک بردارید' : 'یک نوشیدنی و یک خوراکی تهیه کنید'; $('#mission').textContent = !story.done.includes('courage') ? 'مسیر تپه را پیدا کنید' : !story.done.includes('paper') && story.done.includes('wind') ? 'نقاشی را کنار بوته پیدا کنید' : story.done.includes('paper') && !story.route ? 'برای نقاشی یک جای قشنگ پیدا کنید' : readyForPicnic(state) ? 'سبد آماده است؛ به زیرانداز برسید' : state.basket.some(p => p.type === 'drink') ? 'یک خوراکی هم برای پیک‌نیک بردارید' : 'یک نوشیدنی و یک خوراکی تهیه کنید';
} }
function selectChannel(code) { function selectChannel(code) {
$('#product-code').value=code;$('#product-code').removeAttribute('aria-invalid');$('#shop-status').textContent=''; $('#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)); 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 row = $(`#products [data-channel="${code}"]`);
row?.scrollIntoView({block: 'nearest'});
} }
function renderProducts() { function renderProducts() {
syncHUD();$('#products').replaceChildren();$('#channels').replaceChildren(); syncHUD();
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').replaceChildren();
products.forEach(p=>{const card=document.createElement('button');card.type='button';card.className='product-code-card';card.dataset.channel=p.code;card.innerHTML=`<span class="icon">${p.icon}</span><b>کد ${fa(p.channel)} · ${p.name}<small>محصول فرضی کانال ${fa(p.channel)}</small></b><span>${formatHetoman(p.cost)}</span>`;card.onclick=()=>selectChannel(p.code);$('#products').append(card);}); $('#channels').replaceChildren();
products.forEach(p => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = fa(p.channel);
button.dataset.channel = p.code;
button.disabled = !p.available;
button.setAttribute('aria-label', `کانال ${fa(p.channel)}${p.available ? '' : '؛ غیرفعال'}`);
button.onclick = () => selectChannel(p.code);
$('#channels').append(button);
});
products.forEach(p => {
const card = document.createElement('button');
card.type = 'button';
card.className = 'product-code-card';
card.dataset.channel = p.code;
card.innerHTML = `<span class="icon">${p.icon}</span><b>کد ${fa(p.channel)} · ${p.name}<small>محصول فرضی کانال ${fa(p.channel)}</small></b><span>${formatHetoman(p.cost)}</span>`;
card.onclick = () => selectChannel(p.code);
$('#products').append(card);
});
} }
function deliver(p) { function deliver(p) {
activeProduct=p;deliveryTime=3.4;delivery.visible=true;deliveryBody.material=bottleMats[p.type==='snack'?2:p.baseId==='water'?1:0]; activeProduct = p;
renderProducts();shop.close();pressed.clear();held.clear();tone(480,.3); 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 = 'پدر: ببین، دستگاه داره انتخابت رو آماده می‌کنه!'; $('#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);}}
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 => { $('#code-form').onsubmit = e => {
e.preventDefault(); e.preventDefault();
if (state.busy || state.finished || !shop.open) return; if (state.busy || state.finished || !shop.open) return;
@@ -275,84 +523,261 @@ $('#code-form').onsubmit=e=>{
if (!field.value.trim()) error = 'کد محصول را وارد کن.'; if (!field.value.trim()) error = 'کد محصول را وارد کن.';
else if (!product) error = 'کانال قابل خرید نیست؛ عددی از ۲۱ تا ۶۰ وارد کن.'; else if (!product) error = 'کانال قابل خرید نیست؛ عددی از ۲۱ تا ۶۰ وارد کن.';
else if (state.balance < product.cost) error = 'برای این محصول موجودی کافی نداری.'; else if (state.balance < product.cost) error = 'برای این محصول موجودی کافی نداری.';
else if(!purchase({...state,basket:[...state.basket]},product.id))error='برای تکمیل پیک‌نیک، هزینهٔ نوشیدنی و خوراکی را کنار بگذار؛ محصول دیگری انتخاب کن.'; else if (!purchase({
if(error){$('#shop-status').textContent=error;field.setAttribute('aria-invalid','true');field.focus();tone(200,.2);return;} ...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'); field.removeAttribute('aria-invalid');
if (beginPayment(state, payment, product)) { if (beginPayment(state, payment, product)) {
paymentTime=0;shop.close();clearInput();$('#payment-panel').hidden=false;$('#cancel-payment').hidden=false; paymentTime = 0;
shop.close();
clearInput();
$('#payment-panel').hidden = false;
$('#cancel-payment').hidden = false;
$('#payment-message').textContent = `کانال ${fa(product.channel)} · ${product.name} · ${formatHetoman(product.cost)}`; $('#payment-message').textContent = `کانال ${fa(product.channel)} · ${product.name} · ${formatHetoman(product.cost)}`;
$('#swipe-card').disabled=true;$('#payment-result').textContent='در حال نزدیک شدن به کارت‌خوان…'; $('#swipe-card').disabled = true;
$('#payment-result').textContent = 'در حال نزدیک شدن به کارت‌خوان…';
} }
}; };
function swipeCard() { function swipeCard() {
if (!swipePayment(state, payment)) return; if (!swipePayment(state, payment)) return;
paymentTime=0;bankCard.visible=true;$('#swipe-card').disabled=true;$('#cancel-payment').hidden=true; paymentTime = 0;
$('#payment-result').textContent='خرید موفق ✓ محصول در حال تحویل است';tone(1100,.3);syncHUD(); bankCard.visible = true;
$('#swipe-card').disabled = true;
$('#cancel-payment').hidden = true;
$('#payment-result').textContent = 'خرید موفق ✓ محصول در حال تحویل است';
tone(1100, .3);
syncHUD();
} }
$('#swipe-card').onclick = swipeCard; $('#swipe-card').onclick = swipeCard;
$('#cancel-payment').onclick=()=>{if(cancelPayment(state,payment)){$('#payment-panel').hidden=true;bankCard.visible=false;clearInput();}}; $('#cancel-payment').onclick = () => {
$('#product-code').addEventListener('input',()=>{document.querySelectorAll('[data-channel]').forEach(node=>node.classList.remove('selected'));$('#product-code').removeAttribute('aria-invalid');$('#shop-status').textContent='';}); if (cancelPayment(state, payment)) {
$('#interact').onclick=openShop;$('#close').onclick=()=>shop.close(); $('#payment-panel').hidden = true;
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();}); 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())); 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 clearInput() {
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();} pressed.clear();
$('#restart').onclick=reset;$('#again').onclick=reset; held.clear();
$('#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');}
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. // 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();}; 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); 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); 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(); let last = performance.now();
function frame(now) { function frame(now) {
requestAnimationFrame(frame);const dt=Math.min((now-last)/1000,.1);last=now;elapsed+=dt; requestAnimationFrame(frame);
const dt = Math.min((now - last) / 1000, .1);
last = now;
elapsed += dt;
const direction = movementDirection(pressed, held); 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); 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) { if (chapterId && !shop.open && !notebook.open && !ending.open && !document.hidden) {
const chapter = chapters[chapterId], lastLine = chapterLine === chapter.lines.length - 1; const chapter = chapters[chapterId], lastLine = chapterLine === chapter.lines.length - 1;
if (!(lastLine && chapter.action)) { if (!(lastLine && chapter.action)) {
chapterTimer -= dt; chapterTimer -= dt;
if(chapterTimer<=0){if(!lastLine){chapterLine++;renderChapter();}else finishChapter();} if (chapterTimer <= 0) {
if (!lastLine) {
chapterLine++;
renderChapter();
} else finishChapter();
}
} }
} }
encounterActive = chapterId === 'rabbit'; encounterActive = chapterId === 'rabbit';
moving=canWalk({shopOpen:shop.open,notebookOpen:notebook.open,endingOpen:ending.open,busy:state.busy,finished:state.finished})?direction:0; 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); 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)); family.position.set(x, pathHeight(x), pathZ(x));
family.rotation.y = -Math.atan(.255 * Math.cos(x * .17));
const shopping = shop.open || state.busy; 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;} 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; 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)); facing += Math.atan2(Math.sin(desiredFacing - facing), Math.cos(desiredFacing - facing)) * (1 - Math.exp(-dt * 7));
if (moving) gait += dt * 6.2; if (moving) gait += dt * 6.2;
characters.update({dt,time:elapsed,gait,moving:!!moving,facing,shopping,delivery:state.busy,finished:state.finished,watchRabbit:encounterActive}); 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; 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)); 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); if (rabbitSeen) encounterTime = Math.min(4, encounterTime + dt);
camTarget.set(x + 7, state.finished ? 8 : innerWidth < 650 ? 12 : 9, innerWidth < 650 ? 19 : 13); camTarget.set(x + 7, state.finished ? 8 : innerWidth < 650 ? 12 : 9, innerWidth < 650 ? 19 : 13);
lookTarget.set(x + 1, .8 + pathHeight(x), pathZ(x)); 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 (shopping) {
if(encounterActive){camTarget.set(x+5,5.5,pathZ(x)+9);lookTarget.set(-2.9,.65,pathZ(-2.5)+1);} 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) { if (!shop.open && !notebook.open && !ending.open) {
const zoomInput = Number(pressed.has('arrowdown') || held.has('out')) - Number(pressed.has('arrowup') || held.has('in')); 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); zoomDistance = THREE.MathUtils.clamp(zoomDistance + zoomInput * dt * .45, .6, 1.6);
} }
camTarget.sub(lookTarget).multiplyScalar(zoomDistance).add(lookTarget); camTarget.sub(lookTarget).multiplyScalar(zoomDistance).add(lookTarget);
if (['approaching', 'ready', 'approved'].includes(payment.phase)) { if (['approaching', 'ready', 'approved'].includes(payment.phase)) {
const reader=new THREE.Vector3(.67,1.7,.68);machine.localToWorld(reader); const reader = new THREE.Vector3(.67, 1.7, .68);
lookTarget.copy(reader);camTarget.copy(reader).add(new THREE.Vector3(.8,.35,1.8)); 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); 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); document.body.classList.toggle('cinematic', shopping || encounterActive);
const near = Math.abs(x - stationX) < 2.5; const near = Math.abs(x - stationX) < 2.5;
// Project the prompt from the reader into screen space; it follows the machine. // Project the prompt from the reader into screen space; it follows the machine.
camera.updateMatrixWorld();machine.updateMatrixWorld(true); camera.updateMatrixWorld();
machine.updateMatrixWorld(true);
const promptPoint = machine.localToWorld(new THREE.Vector3(.55, 2.15, .9)).project(camera); 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; const promptVisible = near && !shop.open && !notebook.open && !ending.open && !state.busy && !state.finished && promptPoint.z > -1 && promptPoint.z < 1;
$('#interact').hidden = !promptVisible; $('#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`;} 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; $('#machine-note').hidden = !promptVisible;
document.body.classList.toggle('near-machine', promptVisible); document.body.classList.toggle('near-machine', promptVisible);
celebrate.hidden = !state.finished; celebrate.hidden = !state.finished;
@@ -364,29 +789,61 @@ function frame(now){
if (x > 21 && storyReady(story) && readyForPicnic(state) && !state.busy && !state.finished) finish(); if (x > 21 && storyReady(story) && readyForPicnic(state) && !state.busy && !state.finished) finish();
if (payment.phase !== 'idle') { if (payment.phase !== 'idle') {
paymentTime += dt; 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 === 'approaching' && paymentTime > 1.2) {
payment.phase = 'ready';
$('#swipe-card').disabled = false;
$('#cancel-payment').hidden = false;
$('#payment-result').textContent = 'دکمهٔ «کشیدن کارت» یا کلید C را بزن.';
}
if (payment.phase === 'approved') { if (payment.phase === 'approved') {
bankCard.position.set(.92 - Math.min(paymentTime, 1) * .35, 1.86 - Math.min(paymentTime, 1) * .32, .78); 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 (paymentTime > 1.35) {
const product = payment.product;
payment = createPayment();
$('#payment-panel').hidden = true;
bankCard.visible = false;
deliver(product);
}
} }
} }
if (deliveryTime > 0) { if (deliveryTime > 0) {
deliveryTime=Math.max(0,deliveryTime-dt);const t=3.4-deliveryTime; deliveryTime = Math.max(0, deliveryTime - dt);
const t = 3.4 - deliveryTime;
const slotIndex = (activeProduct?.channel ?? 22) - 1; 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 start = new THREE.Vector3(-.65 + (slotIndex % 10) * .115, .965 + (5 - Math.floor(slotIndex / 10)) * .275, .76);
const slot=new THREE.Vector3(-.13,.35,.86);machine.localToWorld(slot); machine.localToWorld(start);
const hand=new THREE.Vector3(.5,.65,-.15);family.localToWorld(hand); 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); if (t < 1.1) delivery.position.lerpVectors(start, slot, t / 1.1);
else if(t<1.8){delivery.position.copy(slot);} else if (t < 1.8) {
else delivery.position.lerpVectors(slot,hand,Math.min(1,(t-1.8)/1.3)); 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; 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();} 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; }
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); 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;}); trees.forEach((tree, i) => {
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); tree.rotation.z = Math.sin(elapsed * .65 + i) * .012;
if(soundEnabled&&elapsed>nextBird){tone(1500+Math.random()*900,.2,.018);nextBird=elapsed+3+Math.random()*4;} });
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); renderer.render(scene, camera);
} }
$('#loading').remove();syncHUD();requestAnimationFrame(frame);
$('#loading').remove();
syncHUD();
requestAnimationFrame(frame);
+27 -7
View File
@@ -1,16 +1,36 @@
import {purchase} from './game-state.js'; import {purchase} from './game-state.js';
export function createPayment(){return {phase:'idle',product:null};}
export function beginPayment(state,payment,product){ export function createPayment() {
if(payment.phase!=='idle'||!product||!purchase({...state,basket:[...state.basket]},product.id))return false; return {phase: 'idle', product: null};
payment.product=product;payment.phase='approaching';state.busy=true;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 swipePayment(state, payment) { export function swipePayment(state, payment) {
if (payment.phase !== 'ready') return false; if (payment.phase !== 'ready') return false;
state.busy = false; state.busy = false;
if(!purchase(state,payment.product.id)){payment.phase='idle';payment.product=null;return false;} if (!purchase(state, payment.product.id)) {
payment.phase='approved';return true; payment.phase = 'idle';
payment.product = null;
return false;
} }
payment.phase = 'approved';
return true;
}
export function cancelPayment(state, payment) { export function cancelPayment(state, payment) {
if (!['approaching', 'ready'].includes(payment.phase)) return false; if (!['approaching', 'ready'].includes(payment.phase)) return false;
payment.phase='idle';payment.product=null;state.busy=false;return true; payment.phase = 'idle';
payment.product = null;
state.busy = false;
return true;
} }
+47 -8
View File
@@ -1,17 +1,37 @@
export const chapters = { export const chapters = {
morning: {title: 'تپهٔ آرزوها', lines: ['پدر: بریم تپه؟ امروز راه رو با هم کشف می‌کنیم.']}, morning: {title: 'تپهٔ آرزوها', lines: ['پدر: بریم تپه؟ امروز راه رو با هم کشف می‌کنیم.']},
rabbit:{title:'یک دوست کوچک',lines:['بچه: یه خرگوش! آروم نگاهش کنیم؟'],action:{value:'watch',label:'تماشای خرگوش'}}, rabbit: {
title: 'یک دوست کوچک',
lines: ['بچه: یه خرگوش! آروم نگاهش کنیم؟'],
action: {value: 'watch', label: 'تماشای خرگوش'}
},
bridge: {title: 'روی پل', lines: ['پدر: دستم رو بگیر؛ با هم رد می‌شیم.']}, bridge: {title: 'روی پل', lines: ['پدر: دستم رو بگیر؛ با هم رد می‌شیم.']},
fish: {title: 'زیر پل', lines: ['بچه: ماهی‌ها رو ببین!']}, fish: {title: 'زیر پل', lines: ['بچه: ماهی‌ها رو ببین!']},
courage: {title: 'اولین خرید من', lines: ['پدر: حالا برای پیک‌نیک خرید کنیم.']}, courage: {title: 'اولین خرید من', lines: ['پدر: حالا برای پیک‌نیک خرید کنیم.']},
wind: {title: 'باد و نقاشی', lines: ['بچه: نقاشیم رفت کنار بوته!']}, wind: {title: 'باد و نقاشی', lines: ['بچه: نقاشیم رفت کنار بوته!']},
paper:{title:'پیداش کردیم',lines:['بچه: سالمه! بالای تپه کاملش می‌کنم.'],action:{value:'recover',label:'برداشتن نقاشی'}}, paper: {
fork:{title:'مسیر تپه',lines:['پدر: از راه آفتابی به تپه می‌رسیم.'],action:{value:'sunny',label:'ادامهٔ مسیر'}}, title: 'پیداش کردیم',
lines: ['بچه: سالمه! بالای تپه کاملش می‌کنم.'],
action: {value: 'recover', label: 'برداشتن نقاشی'}
},
fork: {
title: 'مسیر تپه',
lines: ['پدر: از راه آفتابی به تپه می‌رسیم.'],
action: {value: 'sunny', label: 'ادامهٔ مسیر'}
},
sunny: {title: 'رنگ‌های جنگل', lines: ['بچه: این رنگ‌ها رو توی نقاشیم می‌کشم!']}, sunny: {title: 'رنگ‌های جنگل', lines: ['بچه: این رنگ‌ها رو توی نقاشیم می‌کشم!']},
shade: {title: 'درخت خاطره‌ها', lines: ['پدر: بچه که بودم هم زیر همین درخت می‌نشستم.']}, shade: {title: 'درخت خاطره‌ها', lines: ['پدر: بچه که بودم هم زیر همین درخت می‌نشستم.']},
summit:{title:'کنار هم',lines:['بچه: دفعهٔ بعد، من راه رو نشونت می‌دم!'],action:{value:'gold',label:'کامل کردن نقاشی'}} summit: {
title: 'کنار هم',
lines: ['بچه: دفعهٔ بعد، من راه رو نشونت می‌دم!'],
action: {value: 'gold', label: 'کامل کردن نقاشی'}
}
}; };
export function createStory(){return {done:[],rabbitChoice:null,route:null,sky:null};}
export function createStory() {
return {done: [], rabbitChoice: null, route: null, sky: null};
}
export function completeChapter(story, id, choice) { export function completeChapter(story, id, choice) {
if (!chapters[id] || story.done.includes(id)) return false; if (!chapters[id] || story.done.includes(id)) return false;
if (chapters[id].action && chapters[id].action.value !== choice) return false; if (chapters[id].action && chapters[id].action.value !== choice) return false;
@@ -21,6 +41,7 @@ export function completeChapter(story,id,choice){
if (id === 'summit') story.sky = choice; if (id === 'summit') story.sky = choice;
return true; return true;
} }
export function nextChapter(story, x) { export function nextChapter(story, x) {
const done = id => story.done.includes(id); const done = id => story.done.includes(id);
if (!done('morning')) return 'morning'; if (!done('morning')) return 'morning';
@@ -30,6 +51,24 @@ export function nextChapter(story,x){
if (!done(story.route)) return x >= 19 ? story.route : null; if (!done(story.route)) return x >= 19 ? story.route : null;
return 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 storyReady(story) {
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;} 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;
}
+990 -14
View File
File diff suppressed because it is too large Load Diff