first commit

This commit is contained in:
2026-10-20 11:41:30 +03:30
commit f7c6aee286
18 changed files with 2393 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Game.iml" filepath="$PROJECT_DIR$/.idea/Game.iml" />
</modules>
</component>
</project>
+53
View File
@@ -0,0 +1,53 @@
# تپهٔ آرزوها — روبات مارکت
بازی سه‌بعدی JavaScript با Three.js: پدر و بچه در جنگل راه می‌روند، از دستگاه خرید می‌کنند و به پیک‌نیک می‌رسند. مدل‌ها با کد ساخته شده‌اند.
## اجرا
```sh
npm install
npm run dev
npm run build
node --test tests/*.test.js
```
## کنترل
- چپ / راست یا A / D: حرکت
- بالا / پایین: زوم دوربین، بدون حرکت شخصیت
- دکمه‌های چهارجهته: نگه داشتن برای حرکت یا زوم در موبایل
- E یا دکمهٔ محصولات: باز کردن دستگاه در نزدیکی آن
- شروع دوباره: بازنشانی داستان، موجودی و زوم
گفت‌وگوها کوتاه‌اند و در کادر سفید پایین صفحه خودکار عوض می‌شوند. در طول تمام گفت‌وگوها، از جمله پل، حرکت آزاد است. هنگام خرید، تحویل محصول یا باز بودن دفترچه حرکت موقتاً متوقف می‌شود.
## دستگاه ۶۰ کانال و خرید با هتومان
واحد بازی «هتومان» است: **۱۰ هتومان = ۱۰۰٬۰۰۰ ریال**، یعنی هر هتومان ۱۰٬۰۰۰ ریال. موجودی اولیه ۵۰۰ هتومان است. همهٔ اعداد و قیمت‌ها نمایشی‌اند و پرداخت واقعی ندارند.
دستگاه شش ردیف ده‌تایی با شماره‌های ۱ تا ۶۰ دارد. فقط کانال‌های **۲۱ تا ۶۰** قابل خریدند. با نزدیک شدن به دستگاه، نشان E / تعامل روی کارت‌خوان نمایش داده می‌شود. با E یا لمس نشان، پنجرهٔ خرید باز می‌شود: فقط شماره‌های ۲۱ تا ۶۰ سمت راست و کد، محصول فرضی و قیمت کانال‌های قابل خرید سمت چپ قرار دارند. کلیک روی شماره یا محصول، کد را پر می‌کند؛ «پرداخت» یا Enter مرحلهٔ کارت‌خوان را باز می‌کند. دوربین به کارت‌خوان نزدیک می‌شود؛ سپس با دکمهٔ «کشیدن کارت» یا کلید C مبلغ کسر می‌شود، پیام خرید موفق نمایش داده می‌شود و محصول تحویل داده می‌شود. پیش از کارت کشیدن امکان انصراف بدون کسر موجودی وجود دارد. کدهای فارسی، عربی و انگلیسی پشتیبانی می‌شوند. کانال نامعتبر پول کم نمی‌کند. توضیح تبدیل واحد هم کنار دستگاه و هم داخل پنجره نمایش داده می‌شود.
محصولات فرضی بین کانال‌ها تکرار می‌شوند: آب‌میوه ۳۵، آب ۱۵، بیسکویت ۲۵، شیرکاکائو ۴۰، چای سرد ۴۵، لیموناد ۴۰، شکلات ۳۰، آجیل ۶۵ و کیک ۳۵ هتومان. مسیر تحویل از کانال انتخاب‌شده شروع می‌شود. برای تکمیل مأموریت، هزینهٔ حداقل یک نوشیدنی و یک خوراکی کنار گذاشته می‌شود.
## ساختار
- `src/main.js`: صحنه، دوربین، دستگاه، رابط و حلقهٔ بازی
- `src/controls.js`: جهت حرکت و شرایط مجاز بودن حرکت؛ دیالوگ مانع حرکت نیست
- `src/characters.js`: شخصیت‌های مفصلی، تناسب بدن، لباس، چشم‌ها، قدم زدن و تماس دست‌ها
- `src/payment.js`: مراحل پرداخت، انصراف و جلوگیری از برداشت تکراری
- `src/game-state.js`: قیمت‌های هتومان، موجودی، اعتبارسنجی کد و خرید
- `src/story.js`: متن‌های کوتاه، فصل‌ها، انتخاب مسیر و کشف‌ها
- `src/style.css`: چیدمان واکنش‌گرا و کادر سفید متن
- `tests/`: آزمون هر دو مسیر داستان، حرکت، پول و تبدیل‌های شخصیت‌ها
قیمت‌ها و موجودی فقط در ماژول game-state تعریف شده‌اند. محاسبات با هتومان صحیح انجام می‌شود؛ رابط با ارقام فارسی نمایش می‌دهد.
## محدودیت‌ها
مدل‌ها هنوز هندسی و سبک هستند؛ تناسب بدن و حرکت طبیعی‌تر شده اما مدل انسانی واقع‌گرایانه یا موشن‌کپچر اضافه نشده است. پرداخت واقعی، ذخیرهٔ پیشرفت و برخورد آزاد با درخت‌ها وجود ندارد. بازیکن در مسیر تعیین‌شده حرکت می‌کند. زمان کامل تجربه هنوز با بازیکن اندازه‌گیری نشده است.
پنجره‌های خرید، دفترچه، پایان و پرداخت پس‌زمینهٔ شیشه‌ای نیمه‌شفاف دارند. نشان تعامل به موقعیت سه‌بعدی دستگاه متصل است و با حرکت دوربین جابه‌جا می‌شود.
## تعامل تک‌دکمه‌ای
گزینه‌های چندانتخابی داستان حذف شده‌اند. هر رویداد یک کار مشخص دارد: تماشای خرگوش، برداشتن نقاشی، ادامه در مسیر آفتابی و کامل کردن نقاشی با آسمان طلایی. برای انجام آن، کلید **F** یا همان دکمهٔ روی صفحه را بزنید. متن‌های معمولی خودکار عوض می‌شوند و حرکت آزاد می‌ماند. **E** تعامل با دستگاه و **C** کشیدن کارت است. انتخاب شمارهٔ محصول و دکمهٔ پرداخت در پنجرهٔ دستگاه باقی است.
+1
View File
File diff suppressed because one or more lines are too long
+1174
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "game",
"version": "1.0.0",
"description": "A Persian 3D forest walk and vending-machine game",
"main": "index.js",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"three": "^0.180.0"
},
"devDependencies": {
"vite": "^6.4.3"
}
}
+141
View File
@@ -0,0 +1,141 @@
import * as THREE from 'three';
// Lightweight articulated characters; all coordinates below are in the family group.
export function createFamily() {
const group = new THREE.Group();
const mat = color => new THREE.MeshStandardMaterial({color, roughness: .82});
const skin = mat('#edbd98'), hair = mat('#49382d'), shoes = mat('#273c36');
function shape(parent, geometry, material, position) {
const m = new THREE.Mesh(geometry, material);
m.position.set(...position);
m.castShadow = true;
m.receiveShadow = true;
parent.add(m);
return m;
}
function sphere(parent, r, material, pos, scale = [1, 1, 1]) {
const m = shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
m.scale.set(...scale);
return m;
}
function segment(parent, a, b, r, material) {
const mesh = shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
return {mesh, a, b};
}
function updateSegment(bone, a, b) {
const delta = b.clone().sub(a);
bone.mesh.position.copy(a).add(b).multiplyScalar(.5);
bone.mesh.scale.y = delta.length();
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
}
function makePerson(isChild, z) {
const scale = isChild ? .69 : 1.05, root = new THREE.Group();
root.position.z = z;
group.add(root);
const torso = new THREE.Group();
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;
}
const head = new THREE.Group();
head.position.y = 1.77 * scale;
torso.add(head);
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, .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]);
smile.rotation.z = Math.PI;
if (isChild) {
sphere(head, .29 * scale, mat('#e8b857'), [0, .18 * scale, 0], [1, .5, 1]);
shape(head, new THREE.BoxGeometry(.55 * scale, .04 * scale, .34 * scale), mat('#e8b857'), [0, .13 * scale, .12 * scale]);
} else {
sphere(torso, .22, mat('#a77950'), [0, 1.2 * scale, -.24 * scale], [1, 1.2, .5]);
}
const legs = [-1, 1].map(side => {
const hip = new THREE.Group();
hip.position.set(side * .15 * scale, .83 * scale, 0);
root.add(hip);
shape(hip, new THREE.CapsuleGeometry(.09 * scale, .23 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
const knee = new THREE.Group();
knee.position.y = -.34 * scale;
hip.add(knee);
shape(knee, new THREE.CapsuleGeometry(.08 * scale, .25 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
const foot = shape(knee, new THREE.SphereGeometry(.12 * scale, 12, 8), shoes, [0, -.32 * scale, .065 * scale]);
foot.scale.set(.85, .65, 1.5);
return {hip, knee, foot};
});
const arms = [-1, 1].map(() => ({
upper: segment(group, null, null, .072 * scale, cloth),
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};
}
const father = makePerson(false, -.45), child = makePerson(true, .45);
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;
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.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.knee.rotation.x = finished ? 1.5 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05;
});
});
group.updateMatrixWorld(true);
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
const shoulder = (actor, side) => toFamily(actor, new THREE.Vector3(side * .29 * actor.scale, 1.43 * actor.scale, 0));
// With characters facing +X, father's left and child's right hands meet.
const contact = new THREE.Vector3(0, 1.06, 0);
actors.forEach((actor, index) => actor.arms.forEach((arm, i) => {
const side = i === 0 ? -1 : 1, inside = index === 0 ? side === -1 : side === 1;
const a = shoulder(actor, side);
let target;
if (inside && !shopping && !finished) target = contact.clone();
else if (index === 1 && shopping && side === -1) target = new THREE.Vector3(.5, delivery ? .65 : 1.1, -.15);
else target = toFamily(actor, new THREE.Vector3(side * .34 * actor.scale, .88 * actor.scale, moving ? Math.sin(gait + (i ? Math.PI : 0)) * .2 : 0));
const elbow = a.clone().lerp(target, .5).add(new THREE.Vector3(.07, 0, inside ? 0 : .07));
updateSegment(arm.upper, a, elbow);
updateSegment(arm.lower, elbow, target);
arm.hand.position.copy(target);
}));
}
update({time: 0, gait: 0, moving: false, facing: Math.PI / 2});
return {group, update};
}
+8
View File
@@ -0,0 +1,8 @@
// Dialogue deliberately never locks walking. Only transactional UI and delivery do.
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}) {
return !shopOpen && !notebookOpen && !endingOpen && !busy && !finished;
}
+48
View File
@@ -0,0 +1,48 @@
export const INITIAL_BALANCE = 500;
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'}
];
// 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 products=channels.filter(channel=>channel.available);
export function createState() {
return {balance: INITIAL_BALANCE, basket: [], busy: false, finished: false, recycled: false};
}
export function readyForPicnic(state) {
return state.basket.some(p => p.type === 'drink') && state.basket.some(p => p.type === 'snack');
}
export function purchase(state, id) {
const product = products.find(p => p.id === id);
if (!product || state.busy || state.finished || state.balance < product.cost) return null;
// Keep enough money for the missing picnic category, so the quest stays completable.
const next = [...state.basket, product];
const reserve = ['drink', 'snack'].reduce((total, type) => total + (next.some(p => p.type === type) ? 0 : Math.min(...products.filter(p => p.type === type).map(p => p.cost))), 0);
if (state.balance - product.cost < reserve) return null;
state.balance -= product.cost;
state.basket.push(product);
state.busy = true;
return product;
}
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;
}
+392
View File
@@ -0,0 +1,392 @@
import * as THREE from 'three';
import './style.css';
import {createPayment,beginPayment,swipePayment,cancelPayment} from './payment.js';
import {movementDirection,canWalk} from './controls.js';
import {chapters,createStory,completeChapter,nextChapter,storyReady,routeOffset,discoveries} from './story.js';
import {createFamily} from './characters.js';
import {products,createState,readyForPicnic,purchase,productByCode,channels,formatHetoman} from './game-state.js';
// One shared path coordinate drives both characters, their gait and the camera.
const canvas = document.querySelector('#world');
let renderer;
try {
renderer = new THREE.WebGLRenderer({canvas, antialias: true});
} catch {
document.querySelector('#loading').textContent = 'مرورگر شما گرافیک سه‌بعدی را اجرا نمی‌کند. شتاب‌دهی گرافیکی را فعال کنید.';
throw new Error('WebGL unavailable');
}
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor('#a4b492');
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2('#a4b492', .018);
const camera = new THREE.PerspectiveCamera(42, 1, .1, 140);
scene.add(new THREE.HemisphereLight('#fff3cc', '#375946', 2.8));
const sun = new THREE.DirectionalLight('#ffe5a3', 3.3);
sun.position.set(-5, 12, -8);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, {left: -24, right: 24, top: 24, bottom: -24, far: 60});
scene.add(sun);
let story=createStory(),chapterId=null,chapterLine=0,chapterTimer=0;
const material = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .85, ...extra});
const green = material('#6d975b'), bark = material('#755a3e'), dark = material('#172c2a');
function mesh(geometry, mat, parent = scene, position = [0, 0, 0]) {
const m = new THREE.Mesh(geometry, mat);
m.position.set(...position);
m.castShadow = true;
m.receiveShadow = true;
parent.add(m);
return m;
}
function box(w, h, d, mat, parent, pos) {
return mesh(new THREE.BoxGeometry(w, h, d), mat, parent, pos)
}
function ball(r, mat, parent, pos) {
return mesh(new THREE.SphereGeometry(r, 12, 10), mat, parent, pos)
}
const ground = mesh(new THREE.PlaneGeometry(160, 120), green);
ground.rotation.x = -Math.PI / 2;
// A broad, gently curving ribbon defines the walking route.
function pathZ(x) {
return Math.sin(x * .17) * 1.5 + routeOffset(story,x)
}
const points = [];
for (let x = -42; x <= 50; x += .5) points.push(new THREE.Vector3(x, .025, pathZ(x)));
const curve = new THREE.CatmullRomCurve3(points);
const path = mesh(new THREE.TubeGeometry(curve, 180, 1.35, 8, false), material('#cbb889'));
path.scale.y = .025;
let seed = 42;
function random() {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296
}
const trees=[];
function tree(x, z, s) {
const g = new THREE.Group();
g.position.set(x, 0, z);
g.scale.setScalar(s);
trees.push(g);
scene.add(g);
mesh(new THREE.CylinderGeometry(.17, .3, 2.6, 7), bark, g, [0, 1.3, 0]);
const foliage = material(['#315e42', '#47764b', '#597f4c', '#749759'][Math.floor(random() * 4)]);
for (let i = 0; i < 3; i++) {
mesh(new THREE.ConeGeometry(1.55 - i * .25, 2.6, 7), foliage, g, [0, 2.7 + i * .9, 0]);
}
}
for (let i = 0; i < 130; i++) {
const x = random() * 100 - 45, z = random() * 65 - 33;
if (Math.abs(z - pathZ(x)) > 3.6 && !(Math.abs(x - 9) < 4 && z > -5 && z < 2) && !(x>17 && x<28 && Math.abs(z)<7)) tree(x, z, .7 + random() * 1.2)
}
for (let i = 0; i < 150; i++) {
const x = random() * 65 - 25, z = random() * 32 - 16;
if (Math.abs(z - pathZ(x)) < 1.7) continue;
const rock = mesh(new THREE.DodecahedronGeometry(.13 + random() * .3), material('#8c9c78'), scene, [x, .12, z]);
rock.scale.y = .6;
}
for (let i = 0; i < 100; i++) {
const x = random() * 55 - 20, z = random() * 25 - 12;
if (Math.abs(z - pathZ(x)) < 1.5) continue;
ball(.065, material(i % 3 ? '#eadba0' : '#e4ae89'), scene, [x, .22, z]);
}
const characters=createFamily();
const family=characters.group;
scene.add(family);
// Procedural vending machine, with a canvas sign and visible product rows.
const stationX = 9, stationZ = pathZ(9) - 2.65;
const machine = new THREE.Group();
machine.position.set(stationX, 0, stationZ);
machine.rotation.y = .13;
scene.add(machine);
box(1.75, 2.9, 1.1, dark, machine, [0, 1.48, 0]);
box(1.25, 1.85, .08, material('#a6d5c3', {metalness: .4, roughness: .22}), machine, [-.13, 1.55, .59]);
box(1.13, 1.72, .1, material('#223f3b'), machine, [-.13, 1.55, .65]);
const bottleMats = ['#e5ae47', '#badd81', '#c8704e'].map(c => material(c));
const steel=material('#aeb8b6',{metalness:.8,roughness:.3});
for(let row=0;row<6;row++){
const shelfY=.86+row*.275;box(1.17,.025,.28,steel,machine,[-.13,shelfY,.73]);
for(let col=0;col<10;col++){
const channel=(5-row)*10+col+1,px=-.65+col*.115;
if(channel%3){mesh(new THREE.CylinderGeometry(.033,.04,.155,10),bottleMats[channel%3],machine,[px,shelfY+.105,.73]);box(.035,.035,.035,dark,machine,[px,shelfY+.195,.73]);}
else box(.068,.14,.07,bottleMats[channel%3],machine,[px,shelfY+.09,.73]);
// Each slot has its own dispensing spiral.
const spiral=mesh(new THREE.TorusGeometry(.035,.006,4,10),steel,machine,[px,shelfY+.045,.79]);
}
}
const slots=document.createElement('canvas');slots.width=1000;slots.height=900;
const slotContext=slots.getContext('2d');slotContext.textAlign='center';slotContext.font='bold 25px sans-serif';
for(let row=0;row<6;row++)for(let col=0;col<10;col++){
const n=row*10+col+1;slotContext.fillStyle=n>=21?'#d1f0b3':'#9ca6a0';slotContext.fillRect(col*100+8,row*150+120,84,30);slotContext.fillStyle='#172c2a';slotContext.fillText(String(n),col*100+50,row*150+144);
}
const slotLabels=mesh(new THREE.PlaneGeometry(1.16,1.65),new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(slots),transparent:true}),machine,[-.13,1.56,.82]);
for(const px of [-.77,.48])box(.025,1.85,.08,steel,machine,[px,1.55,.82]);
const glass=mesh(new THREE.PlaneGeometry(1.24,1.86),material('#d5eee9',{transparent:true,opacity:.1,metalness:.3,roughness:.08,depthWrite:false}),machine,[-.13,1.55,.84]);
box(.04,.5,.06,steel,machine,[.52,1.4,.88]);
for(let r=0;r<4;r++)for(let c=0;c<3;c++)box(.045,.045,.035,steel,machine,[.60+c*.065,1.43-r*.07,.64]);
for(let i=0;i<6;i++)box(.24,.014,.025,steel,machine,[.67,.55+i*.045,.58]);
for(const px of [-.65,.65])box(.15,.09,.6,dark,machine,[px,.015,0]);
box(.24, .38, .08, material('#81d9ba', {emissive: '#50a486', emissiveIntensity: .5}), machine, [.67, 1.7, .62]);
for (let i = 0; i < 3; i++) box(.15, .05, .08, material('#e4d7ad'), machine, [.67, 1.3 - i * .1, .62]);
box(.85, .3, .1, material('#050f10'), machine, [-.13, .36, .62]);
const label = document.createElement('canvas');
label.width = 512;
label.height = 128;
const ctx = label.getContext('2d');
ctx.fillStyle = '#193c30';
ctx.fillRect(0, 0, 512, 128);
ctx.fillStyle = '#d9efa7';
ctx.font = 'bold 57px Tahoma';
ctx.textAlign = 'center';
ctx.fillText('روبات مارکت', 256, 83);
const sign = material('#ffffff', {map: new THREE.CanvasTexture(label), emissive: '#9cce91', emissiveIntensity: .18});
box(1.5, .38, .05, sign, machine, [0, 2.68, .58]);
// A separate card, animated through the reader only after an explicit swipe.
const bankCard=box(.24,.15,.014,material('#ddd4b4',{metalness:.2}),machine,[.9,1.8,.8]);
box(.2,.03,.016,dark,bankCard,[0,.035,.008]);bankCard.visible=false;
const pad = mesh(new THREE.CylinderGeometry(2.3, 2.4, .12, 32), material('#9caa80'), scene, [9, .05, stationZ]);
const delivery = new THREE.Group(); scene.add(delivery);
const deliveryBody=mesh(new THREE.CylinderGeometry(.1,.1,.28,10),bottleMats[0],delivery);
box(.09,.05,.09,dark,delivery,[0,.16,0]);
delivery.visible = false;
// Picnic clearing, checked blanket, basket and a recycling bin.
const picnic=new THREE.Group();picnic.position.set(23,1.8,pathZ(22));scene.add(picnic);
for(let i=0;i<6;i++)for(let j=0;j<5;j++)box(.5,.035,.5,material((i+j)%2?'#f3dca9':'#b96f51'),picnic,[(i-2.5)*.5,.08,(j-2)*.5]);
const picnicFood=new THREE.Group();picnic.add(picnicFood);
box(.5,.3,.35,material('#ac8252'),picnic,[-1,.25,-.8]);
mesh(new THREE.TorusGeometry(.22,.035,6,16,Math.PI),bark,picnic,[-1,.5,-.8]);
const bin=box(.55,.8,.55,material('#39735b'),scene,[24,2.2,pathZ(22)-2.3]);
box(.65,.08,.65,dark,scene,[24,2.64,pathZ(22)-2.3]);
const flap=new THREE.Group();flap.position.set(-.13,.53,.69);machine.add(flap);
box(.87,.28,.035,material('#527268',{metalness:.45}),flap,[0,-.14,0]);
const glow=new THREE.PointLight('#a8ffc5',2,7);glow.position.set(9,2,stationZ+1);scene.add(glow);
const butterfly=new THREE.Group();scene.add(butterfly);
const wings=[-1,1].map(side=>{const wing=mesh(new THREE.SphereGeometry(.16,8,6),material('#efb75b'),butterfly,[side*.14,0,0]);wing.scale.set(1,.12,1.4);return wing;});
// A raised footbridge crosses a shallow stream before the vending stop.
const bridgeX=0;
const water=mesh(new THREE.PlaneGeometry(3.4,40),material('#68b4b6',{metalness:.3,roughness:.25}),scene,[bridgeX,.035,0]);water.rotation.x=-Math.PI/2;
const bridge=new THREE.Group();scene.add(bridge);
const wood=material('#a57b50');
for(let i=0;i<13;i++){const bx=-2+i/3;box(.29,.14,2.8,wood,bridge,[bx,.22,pathZ(bx)]);}
for(const side of [-1,1]){
for(const bx of [-2,0,2])box(.12,1,.12,bark,bridge,[bx,.65,pathZ(bx)+side*1.3]);
const rail=box(4.3,.1,.1,wood,bridge,[0,1.08,side*1.3]);rail.rotation.y=-.25;
}
function pathHeight(at){return Math.max(0,Math.min(1,(2.6-Math.abs(at))/.6))*.3+THREE.MathUtils.smoothstep(at,17,22)*1.8;}
const rabbit=new THREE.Group();scene.add(rabbit);
const fur=material('#eee2ca'),pink=material('#d5a193');
const rabbitBody=ball(.28,fur,rabbit,[0,.3,0]);rabbitBody.scale.set(1.3,1,1);
ball(.22,fur,rabbit,[.24,.55,0]);ball(.12,fur,rabbit,[-.35,.4,0]);
for(const side of [-1,1]){const ear=ball(.1,fur,rabbit,[.23,.86,side*.1]);ear.scale.set(.75,2.5,.75);const inset=ball(.065,pink,rabbit,[.29,.88,side*.1]);inset.scale.set(.4,2.6,.7);ball(.025,dark,rabbit,[.43,.58,side*.13]);ball(.09,fur,rabbit,[.1,.1,side*.2]);}
const lostPaper=box(.6,.025,.43,material('#fff3d5'),scene,[14,.35,Math.sin(14*.17)*1.5+1.5]);lostPaper.rotation.z=.15;
const bush=ball(.55,material('#688a4b'),scene,[14,.3,Math.sin(14*.17)*1.5+2]);
const oldTree=new THREE.Group();oldTree.position.set(19,0,-5);scene.add(oldTree);mesh(new THREE.CylinderGeometry(.5,.8,4,9),bark,oldTree,[0,2,0]);ball(2,material('#54734a'),oldTree,[0,4.8,0]);
const hill=mesh(new THREE.SphereGeometry(5,24,16),green,scene,[23,-1.3,0]);hill.scale.set(1,.65,1.3);
let branchPath=null;
function rebuildRoute(){
if(branchPath){scene.remove(branchPath);branchPath.geometry.dispose();branchPath.material.dispose();}
const pts=[];for(let at=11;at<=24;at+=.15)pts.push(new THREE.Vector3(at,pathHeight(at)+.045,pathZ(at)));
branchPath=mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts),80,.8,8,false),material('#cbb889'));
// Flatten only the cross-section, retaining the hill's elevation.
const positions=branchPath.geometry.attributes.position;
for(let i=0;i<positions.count;i++){const px=positions.getX(i);positions.setY(i,pathHeight(px)+.045);}
positions.needsUpdate=true;branchPath.geometry.computeVertexNormals();
}
rebuildRoute();
const fishes=[];for(let i=0;i<4;i++){const fish=ball(.09,material('#e8b365'),scene,[.4,.11,-.6+i*.4]);fish.scale.set(2,.5,1);fishes.push(fish);}
let encounterTime=0,rabbitSeen=false,encounterActive=false,facing=Math.PI/2;
const $=s=>document.querySelector(s),shop=$('#shop'),ending=$('#ending');
const storyDialog=$('#story-dialog'),notebook=$('#notebook');
function renderChapter(){
const chapter=chapters[chapterId];$('#chapter-title').textContent=chapter.title;$('#chapter-line').textContent=chapter.lines[chapterLine];$('#chapter-count').textContent=`${chapterLine+1} / ${chapter.lines.length}`;$('#chapter-actions').replaceChildren();
const last=chapterLine===chapter.lines.length-1;
if(last&&chapter.action){const b=document.createElement('button');b.type='button';b.textContent=`${chapter.action.label} · F`;b.onclick=performStoryAction;$('#chapter-actions').append(b);}
chapterTimer=Math.max(2.5,chapter.lines[chapterLine].length*.055);
}
function performStoryAction(){
if(!chapterId||shop.open||notebook.open||ending.open||state.busy)return;
const chapter=chapters[chapterId];
if(chapter.action&&chapterLine===chapter.lines.length-1)finishChapter(chapter.action.value);
}
function showChapter(id){chapterId=id;chapterLine=0;storyDialog.hidden=false;renderChapter();}
function finishChapter(choice){
const id=chapterId;if(!completeChapter(story,id,choice))return;
if(id==='rabbit'){rabbitSeen=true;encounterTime=0;}
if(id==='fork'||id==='wind')rebuildRoute();
storyDialog.hidden=true;chapterId=null;syncHUD();
if(id==='summit'){drawMemory($('#memory'));$('#ending-discoveries').textContent=discoveries(story).join(' · ');ending.showModal();}
}
function drawMemory(canvas){
const c=canvas.getContext('2d');c.clearRect(0,0,600,360);c.fillStyle=story.sky==='gold'?'#f5d18b':story.sky==='rose'?'#eeb9ba':'#b9dfeb';c.fillRect(0,0,600,360);c.fillStyle='#fff0af';c.beginPath();c.arc(500,65,30,0,Math.PI*2);c.fill();c.fillStyle='#84a36b';c.beginPath();c.moveTo(0,240);c.quadraticCurveTo(160,90,320,235);c.quadraticCurveTo(460,120,600,210);c.lineTo(600,360);c.lineTo(0,360);c.fill();
for(let i=0;i<5;i++){c.fillStyle='#6a553e';c.fillRect(30+i*115,180,8,75);c.fillStyle='#456b47';c.beginPath();c.moveTo(5+i*115,205);c.lineTo(34+i*115,125);c.lineTo(63+i*115,205);c.fill();}
c.strokeStyle='#8a674d';c.lineWidth=8;c.beginPath();c.moveTo(75,278);c.lineTo(210,278);c.moveTo(85,263);c.lineTo(200,263);c.stroke();
function person(px,py,size,color){c.fillStyle='#edbb94';c.beginPath();c.arc(px,py,size*.18,0,Math.PI*2);c.fill();c.strokeStyle=color;c.lineWidth=size*.16;c.beginPath();c.moveTo(px,py+size*.22);c.lineTo(px,py+size*.65);c.moveTo(px,py+size*.3);c.lineTo(px+size*.3,py+size*.48);c.moveTo(px,py+size*.65);c.lineTo(px-size*.15,py+size);c.moveTo(px,py+size*.65);c.lineTo(px+size*.15,py+size);c.stroke();}
person(300,200,90,'#b28b48');person(347,235,58,'#c56f4d');c.strokeStyle='#d4a77d';c.lineWidth=5;c.beginPath();c.moveTo(327,243);c.lineTo(347,252);c.stroke();c.font='20px Tahoma';c.textAlign='center';c.fillStyle='#294b3c';c.fillText(story.sky?'من و بابا، تپهٔ آرزوها':'من و بابا… ادامه دارد',300,338);
}
$('#notebook-open').onclick=()=>{clearInput();$('#discoveries').textContent=discoveries(story).join(' · ')||'هنوز اول راهیم؛ کشف‌های امروز اینجا ثبت می‌شن.';drawMemory($('#drawing'));notebook.showModal();};$('#notebook-close').onclick=()=>notebook.close();notebook.addEventListener('close',clearInput);
let payment=createPayment(),paymentTime=0;
let state=createState(),x=-8,moving=0,gait=0,deliveryTime=0,activeProduct=null,elapsed=0;
const pressed=new Set(),held=new Set();
const fa=n=>n.toLocaleString('fa-IR');
let audio=null,soundEnabled=false,nextBird=0,zoomDistance=1;
function tone(frequency,duration=.12,volume=.04){if(!soundEnabled||!audio)return;const oscillator=audio.createOscillator(),gain=audio.createGain();oscillator.frequency.setValueAtTime(frequency,audio.currentTime);oscillator.frequency.exponentialRampToValueAtTime(frequency*.7,audio.currentTime+duration);gain.gain.setValueAtTime(volume,audio.currentTime);gain.gain.exponentialRampToValueAtTime(.001,audio.currentTime+duration);oscillator.connect(gain).connect(audio.destination);oscillator.start();oscillator.stop(audio.currentTime+duration);}
$('#sound').onclick=async()=>{try{audio??=new AudioContext();await audio.resume();soundEnabled=!soundEnabled;$('#sound').textContent=soundEnabled?'صدا: روشن ♫':'صدا: خاموش ♫';$('#sound').setAttribute('aria-pressed',String(soundEnabled));tone(700);}catch{$('#sound').textContent='صدا در دسترس نیست';}};
function syncHUD(){
$('#balance').textContent=fa(state.balance);
$('#basket').textContent=state.basket.length?'🧺 '+state.basket.map(p=>p.icon).join(' '):'🧺 سبد پیک‌نیک خالی است';
$('#mission').textContent=!story.done.includes('courage')?'مسیر تپه را پیدا کنید':!story.done.includes('paper')&&story.done.includes('wind')?'نقاشی را کنار بوته پیدا کنید':story.done.includes('paper')&&!story.route?'برای نقاشی یک جای قشنگ پیدا کنید':readyForPicnic(state)?'سبد آماده است؛ به زیرانداز برسید':state.basket.some(p=>p.type==='drink')?'یک خوراکی هم برای پیک‌نیک بردارید':'یک نوشیدنی و یک خوراکی تهیه کنید';
}
function selectChannel(code){
$('#product-code').value=code;$('#product-code').removeAttribute('aria-invalid');$('#shop-status').textContent='';
document.querySelectorAll('[data-channel]').forEach(node=>node.classList.toggle('selected',node.dataset.channel===code));
const row=$(`#products [data-channel="${code}"]`);row?.scrollIntoView({block:'nearest'});
}
function renderProducts(){
syncHUD();$('#products').replaceChildren();$('#channels').replaceChildren();
products.forEach(p=>{const button=document.createElement('button');button.type='button';button.textContent=fa(p.channel);button.dataset.channel=p.code;button.disabled=!p.available;button.setAttribute('aria-label',`کانال ${fa(p.channel)}${p.available?'':'؛ غیرفعال'}`);button.onclick=()=>selectChannel(p.code);$('#channels').append(button);});
products.forEach(p=>{const card=document.createElement('button');card.type='button';card.className='product-code-card';card.dataset.channel=p.code;card.innerHTML=`<span class="icon">${p.icon}</span><b>کد ${fa(p.channel)} · ${p.name}<small>محصول فرضی کانال ${fa(p.channel)}</small></b><span>${formatHetoman(p.cost)}</span>`;card.onclick=()=>selectChannel(p.code);$('#products').append(card);});
}
function deliver(p){
activeProduct=p;deliveryTime=3.4;delivery.visible=true;deliveryBody.material=bottleMats[p.type==='snack'?2:p.baseId==='water'?1:0];
renderProducts();shop.close();pressed.clear();held.clear();tone(480,.3);
$('#speech').textContent='پدر: ببین، دستگاه داره انتخابت رو آماده می‌کنه!';
}
function openShop(){if(Math.abs(x-stationX)<2.5&&!shop.open&&!notebook.open&&!state.busy&&!state.finished){pressed.clear();held.clear();$('#shop-status').textContent='بچه: خودم می‌تونم انتخاب کنم؟ پدر: حتماً! برای نوشیدنی و خوراکی پول کنار بگذار.';renderProducts();$('#product-code').value='';$('#product-code').removeAttribute('aria-invalid');shop.showModal();$('#product-code').focus();tone(750);}}
$('#code-form').onsubmit=e=>{
e.preventDefault();
if(state.busy||state.finished||!shop.open)return;
const field=$('#product-code'),product=productByCode(field.value);
let error='';
if(!field.value.trim())error='کد محصول را وارد کن.';
else if(!product)error='کانال قابل خرید نیست؛ عددی از ۲۱ تا ۶۰ وارد کن.';
else if(state.balance<product.cost)error='برای این محصول موجودی کافی نداری.';
else if(!purchase({...state,basket:[...state.basket]},product.id))error='برای تکمیل پیک‌نیک، هزینهٔ نوشیدنی و خوراکی را کنار بگذار؛ محصول دیگری انتخاب کن.';
if(error){$('#shop-status').textContent=error;field.setAttribute('aria-invalid','true');field.focus();tone(200,.2);return;}
field.removeAttribute('aria-invalid');
if(beginPayment(state,payment,product)){
paymentTime=0;shop.close();clearInput();$('#payment-panel').hidden=false;$('#cancel-payment').hidden=false;
$('#payment-message').textContent=`کانال ${fa(product.channel)} · ${product.name} · ${formatHetoman(product.cost)}`;
$('#swipe-card').disabled=true;$('#payment-result').textContent='در حال نزدیک شدن به کارت‌خوان…';
}
};
function swipeCard(){
if(!swipePayment(state,payment))return;
paymentTime=0;bankCard.visible=true;$('#swipe-card').disabled=true;$('#cancel-payment').hidden=true;
$('#payment-result').textContent='خرید موفق ✓ محصول در حال تحویل است';tone(1100,.3);syncHUD();
}
$('#swipe-card').onclick=swipeCard;
$('#cancel-payment').onclick=()=>{if(cancelPayment(state,payment)){$('#payment-panel').hidden=true;bankCard.visible=false;clearInput();}};
$('#product-code').addEventListener('input',()=>{document.querySelectorAll('[data-channel]').forEach(node=>node.classList.remove('selected'));$('#product-code').removeAttribute('aria-invalid');$('#shop-status').textContent='';});
$('#interact').onclick=openShop;$('#close').onclick=()=>shop.close();
window.addEventListener('keydown',e=>{if(e.key.toLowerCase()==='f'){if(!e.repeat)performStoryAction();return;}if(e.key.toLowerCase()==='c'&&payment.phase==='ready'){e.preventDefault();swipeCard();return;}if(notebook.open||ending.open)return;if(['ArrowRight','ArrowLeft','ArrowUp','ArrowDown',' '].includes(e.key)&&!shop.open&&!ending.open)e.preventDefault();pressed.add(e.key.toLowerCase());if(e.key.toLowerCase()==='e')openShop();});
window.addEventListener('keyup',e=>pressed.delete(e.key.toLowerCase()));
function clearInput(){pressed.clear();held.clear();}window.addEventListener('blur',clearInput);document.addEventListener('visibilitychange',clearInput);shop.addEventListener('close',clearInput);
for(const [id,dir] of [['back',-1],['forward',1],['zoom-in','in'],['zoom-out','out']]){const b=$('#'+id);b.onpointerdown=e=>{if((state.busy||state.finished)&&typeof dir==='number')return;b.setPointerCapture(e.pointerId);held.add(dir);};b.onpointerup=b.onpointercancel=b.onlostpointercapture=()=>held.delete(dir);}
function reset(){payment=createPayment();paymentTime=0;$('#payment-panel').hidden=true;bankCard.visible=false;story=createStory();chapterId=null;storyDialog.hidden=true;chapterTimer=0;if(notebook.open)notebook.close();rebuildRoute();state=createState();x=-8;deliveryTime=0;delivery.visible=false;activeProduct=null;flap.rotation.x=0;encounterTime=0;rabbitSeen=false;encounterActive=false;facing=Math.PI/2;zoomDistance=1;picnicFood.clear();clearInput();if(shop.open)shop.close();if(ending.open)ending.close();$('#badge').textContent='';$('#recycle').disabled=false;$('#recycle').textContent='بسته‌ها را در سطل بینداز ♻';$('#speech').textContent='بچه: بابا، برای پیک‌نیک چی بخریم؟';syncHUD();}
$('#restart').onclick=reset;$('#again').onclick=reset;
$('#recycle').onclick=()=>{state.recycled=true;$('#badge').textContent='🏅 نشان دوست جنگل را گرفتی!';$('#recycle').disabled=true;$('#recycle').textContent='جنگل تمیز ماند ✓';$('#speech').textContent='بچه: چیزی جا نذاشتیم؟ پدر: فقط رد پامون.';tone(880,.4);};
function finish(){state.finished=true;clearInput();state.basket.slice(0,10).forEach((p,i)=>{const item=mesh(p.type==='drink'?new THREE.CylinderGeometry(.09,.09,.3,8):new THREE.BoxGeometry(.25,.09,.2),bottleMats[p.type==='drink'?0:2],picnicFood,[-.5+(i%5)*.28,.25,Math.floor(i/5)*.3]);});$('#ending-text').textContent=`با ${fa(state.basket.length)} خوراکی و ${formatHetoman(state.balance)} باقی‌مانده، وقت یک استراحت دونفره است.`;$('#ending-basket').textContent=state.basket.map(p=>p.icon).join(' ');$('#speech').textContent='بچه: بهترین قسمت گردش، با تو بودن بود بابا!';$('#mission').textContent='پیک‌نیک آماده است؛ یک خاطرهٔ خوب ساختید';tone(660,.5);showChapter('summit');}
// The ending opens on request so the picnic remains visible before the overlay.
const celebrate=document.createElement('button');celebrate.id='celebrate';celebrate.textContent='پیک‌نیک و نشان دوست جنگل 🌿';celebrate.hidden=true;document.body.append(celebrate);celebrate.onclick=()=>{if(!story.done.includes('summit'))showChapter('summit');else ending.showModal();};
ending.addEventListener('cancel',clearInput);
function resize(){renderer.setSize(innerWidth,innerHeight);camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();}window.addEventListener('resize',resize);resize();
const camTarget=new THREE.Vector3(),look=new THREE.Vector3(x+1,.8,pathZ(x)),lookTarget=new THREE.Vector3();camera.position.set(x+7,10,14);
let last=performance.now();
function frame(now){
requestAnimationFrame(frame);const dt=Math.min((now-last)/1000,.1);last=now;elapsed+=dt;
const direction=movementDirection(pressed,held);
const next=nextChapter(story,x);if(next&&!chapterId&&!shop.open&&!ending.open&&!notebook.open&&!state.busy&&!state.finished)showChapter(next);
if(chapterId&&!shop.open&&!notebook.open&&!ending.open&&!document.hidden){
const chapter=chapters[chapterId],lastLine=chapterLine===chapter.lines.length-1;
if(!(lastLine&&chapter.action)){
chapterTimer-=dt;
if(chapterTimer<=0){if(!lastLine){chapterLine++;renderChapter();}else finishChapter();}
}
}
encounterActive=chapterId==='rabbit';
moving=canWalk({shopOpen:shop.open,notebookOpen:notebook.open,endingOpen:ending.open,busy:state.busy,finished:state.finished})?direction:0;
x=THREE.MathUtils.clamp(x+moving*2.2*dt,-9,22);
family.position.set(x,pathHeight(x),pathZ(x));family.rotation.y=-Math.atan(.255*Math.cos(x*.17));
const shopping=shop.open||state.busy;
if(shopping){family.position.x=THREE.MathUtils.lerp(x,stationX,1-Math.exp(-dt*8));x=family.position.x;}
const desiredFacing=shopping?Math.PI:moving<0?-Math.PI/2:moving>0?Math.PI/2:facing;
facing+=Math.atan2(Math.sin(desiredFacing-facing),Math.cos(desiredFacing-facing))*(1-Math.exp(-dt*7));
if(moving)gait+=dt*6.2;
characters.update({dt,time:elapsed,gait,moving:!!moving,facing,shopping,delivery:state.busy,finished:state.finished,watchRabbit:encounterActive});
if(state.finished)family.position.x=22.7;
rabbit.position.set(-2.5+(rabbitSeen?Math.min(4,encounterTime)*.8:0),rabbitSeen?Math.abs(Math.sin(elapsed*8))*.3:0,pathZ(-2.5)+2.5+(rabbitSeen?Math.min(4,encounterTime):0));
if(rabbitSeen)encounterTime=Math.min(4,encounterTime+dt);
camTarget.set(x+7,state.finished?8:innerWidth<650?12:9,innerWidth<650?19:13);
lookTarget.set(x+1,.8+pathHeight(x),pathZ(x));
if(shopping){camTarget.set(stationX+4.2,3.5,stationZ+(innerWidth<650?8:6));lookTarget.set(stationX,.95,stationZ+.9);}
if(encounterActive){camTarget.set(x+5,5.5,pathZ(x)+9);lookTarget.set(-2.9,.65,pathZ(-2.5)+1);}
if(!shop.open&&!notebook.open&&!ending.open){
const zoomInput=Number(pressed.has('arrowdown')||held.has('out'))-Number(pressed.has('arrowup')||held.has('in'));
zoomDistance=THREE.MathUtils.clamp(zoomDistance+zoomInput*dt*.45,.6,1.6);
}
camTarget.sub(lookTarget).multiplyScalar(zoomDistance).add(lookTarget);
if(['approaching','ready','approved'].includes(payment.phase)){
const reader=new THREE.Vector3(.67,1.7,.68);machine.localToWorld(reader);
lookTarget.copy(reader);camTarget.copy(reader).add(new THREE.Vector3(.8,.35,1.8));
}
camera.position.lerp(camTarget,1-Math.exp(-dt*2.8));look.lerp(lookTarget,1-Math.exp(-dt*3));camera.lookAt(look);
document.body.classList.toggle('cinematic',shopping||encounterActive);
const near=Math.abs(x-stationX)<2.5;
// Project the prompt from the reader into screen space; it follows the machine.
camera.updateMatrixWorld();machine.updateMatrixWorld(true);
const promptPoint=machine.localToWorld(new THREE.Vector3(.55,2.15,.9)).project(camera);
const promptVisible=near&&!shop.open&&!notebook.open&&!ending.open&&!state.busy&&!state.finished&&promptPoint.z>-1&&promptPoint.z<1;
$('#interact').hidden=!promptVisible;
if(promptVisible){$('#interact').style.left=`${THREE.MathUtils.clamp((promptPoint.x*.5+.5)*innerWidth,85,innerWidth-85)}px`;$('#interact').style.top=`${THREE.MathUtils.clamp((-.5*promptPoint.y+.5)*innerHeight,85,innerHeight-100)}px`;}
$('#machine-note').hidden=!promptVisible;
document.body.classList.toggle('near-machine',promptVisible);
celebrate.hidden=!state.finished;
$('#progress').style.width=`${Math.min(100,(state.basket.some(p=>p.type==='drink')?30:0)+(state.basket.some(p=>p.type==='snack')?30:0)+(state.finished?40:Math.max(0,(x-11)/11)*35))}%`;
if(!state.busy&&!state.finished&&!encounterActive){
$('#speech').textContent=near?(readyForPicnic(state)?'بچه: همه‌چی داریم! بریم سر زیرانداز؟':'بچه: بابا، این فروشنده نداره؟ پدر: خودت انتخاب کن، دستگاه بهت می‌ده!'):x>20&&!readyForPicnic(state)?'پدر: برای پیک‌نیک یک نوشیدنی و یک خوراکی لازم داریم؛ برگردیم دستگاه.':x>12?'بچه: اون زیرانداز رو ببین! رسیدیم؟':x>0?'پدر: صدای پرنده‌ها رو می‌شنوی؟ آروم راه بریم.':'بچه: بابا، برای پیک‌نیک چی بخریم؟';
}
if(encounterActive)$('#speech').textContent='بچه: بابا نگاه کن! یه خرگوش کوچولو!';
if(x>21&&storyReady(story)&&readyForPicnic(state)&&!state.busy&&!state.finished)finish();
if(payment.phase!=='idle'){
paymentTime+=dt;
if(payment.phase==='approaching'&&paymentTime>1.2){payment.phase='ready';$('#swipe-card').disabled=false;$('#cancel-payment').hidden=false;$('#payment-result').textContent='دکمهٔ «کشیدن کارت» یا کلید C را بزن.';}
if(payment.phase==='approved'){
bankCard.position.set(.92-Math.min(paymentTime,1)*.35,1.86-Math.min(paymentTime,1)*.32,.78);
if(paymentTime>1.35){const product=payment.product;payment=createPayment();$('#payment-panel').hidden=true;bankCard.visible=false;deliver(product);}
}
}
if(deliveryTime>0){
deliveryTime=Math.max(0,deliveryTime-dt);const t=3.4-deliveryTime;
const slotIndex=(activeProduct?.channel??22)-1;
const start=new THREE.Vector3(-.65+(slotIndex%10)*.115,.965+(5-Math.floor(slotIndex/10))*.275,.76);machine.localToWorld(start);
const slot=new THREE.Vector3(-.13,.35,.86);machine.localToWorld(slot);
const hand=new THREE.Vector3(.5,.65,-.15);family.localToWorld(hand);
if(t<1.1)delivery.position.lerpVectors(start,slot,t/1.1);
else if(t<1.8){delivery.position.copy(slot);}
else delivery.position.lerpVectors(slot,hand,Math.min(1,(t-1.8)/1.3));
flap.rotation.x=t>1&&t<2.6?-1:0;
if(!deliveryTime){state.busy=false;delivery.visible=false;flap.rotation.x=0;tone(980,.18);syncHUD();}
}
lostPaper.visible=!story.done.includes('paper');lostPaper.rotation.y=Math.sin(elapsed)*.15;
fishes.forEach((fish,i)=>fish.position.z=Math.sin(elapsed*.8+i)*.8);
trees.forEach((tree,i)=>{tree.rotation.z=Math.sin(elapsed*.65+i)*.012;});
butterfly.position.set(x+1.6+Math.sin(elapsed)*.5,2+Math.sin(elapsed*1.4)*.4,pathZ(x)+1.8);wings.forEach((wing,i)=>wing.rotation.z=Math.sin(elapsed*15)*(i?1:-1)*.7);
if(soundEnabled&&elapsed>nextBird){tone(1500+Math.random()*900,.2,.018);nextBird=elapsed+3+Math.random()*4;}
renderer.render(scene,camera);
}
$('#loading').remove();syncHUD();requestAnimationFrame(frame);
+16
View File
@@ -0,0 +1,16 @@
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 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;
}
+35
View File
@@ -0,0 +1,35 @@
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:'کامل کردن نقاشی'}}
};
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;
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;
}
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;}
+405
View File
@@ -0,0 +1,405 @@
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;900&display=swap');
:root {
font-family: Vazirmatn, Tahoma, sans-serif;
color: #fff6df;
background: #132e29;
font-synthesis: none
}
* {
box-sizing: border-box
}
body {
margin: 0;
overflow: hidden
}
button, a {
-webkit-tap-highlight-color: transparent
}
button {
font: inherit;
cursor: pointer
}
button:focus-visible, a:focus-visible {
outline: 3px solid #ffd774;
outline-offset: 4px
}
#world {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
touch-action: none
}
header {
position: absolute;
top: 32px;
left: 5%;
right: 5%;
display: flex;
justify-content: space-between;
align-items: center
}
.brand {
color: #fff6df;
text-decoration: none;
font-size: 27px;
font-weight: 900;
line-height: 1
}
.brand span {
color: #bce598
}
.brand small {
display: block;
font-size: 9px;
letter-spacing: 2px;
margin-top: 12px;
font-weight: 400
}
.wallet {
border: 1px solid #ffffff30;
background: #17372d90;
backdrop-filter: blur(12px);
padding: 10px 20px;
border-radius: 30px;
display: flex;
gap: 9px;
align-items: center
}
.wallet strong {
font-size: 23px
}
.wallet span {
font-size: 12px;
color: #d1dacb
}
.intro {
position: absolute;
top: 19%;
right: 6%;
pointer-events: none
}
.eyebrow {
color: #d4e9a9;
font-size: 12px;
letter-spacing: 1px
}
h1 {
font-size: clamp(44px, 6vw, 88px);
line-height: 1.3;
margin: 12px 0;
font-weight: 900
}
h1 em {
font-style: normal;
color: #c5e9a4
}
.intro p {
color: #d7dfcf;
font-size: 14px
}
.mission {
position: absolute;
left: 5%;
top: 140px;
width: 230px;
padding: 20px;
background: #14352c70;
border: 1px solid #ffffff20;
border-radius: 18px;
backdrop-filter: blur(10px)
}
.mission span {
font-size: 11px;
color: #c5e0b0
}
.mission p {
font-size: 14px
}
.progress {
height: 3px;
background: #ffffff20;
border-radius: 5px;
overflow: hidden
}
.progress i {
display: block;
height: 100%;
width: 0;
background: #d8eda5;
transition: width .2s
}
#speech {
position: absolute;
bottom: 24%;
left: 50%;
transform: translateX(-50%);
background: #fff8e9;
color: #29453b;
padding: 13px 22px;
border-radius: 18px 18px 3px 18px;
font-size: 14px;
max-width: 85%;
text-align: center;
box-shadow: 0 8px 40px #0002
}
#interact {
position: absolute;
bottom: 15%;
left: 50%;
transform: translateX(-50%);
background: #d6eda5;
color: #233f32;
border: 0;
border-radius: 40px;
padding: 12px 25px;
box-shadow: 0 6px 30px #0002
}
#interact span {
background: #284c37;
color: white;
border-radius: 5px;
padding: 2px 7px;
margin-right: 10px
}
footer {
position: absolute;
bottom: 24px;
right: 5%;
left: 5%;
display: flex;
justify-content: space-between;
align-items: center;
color: #e5edda;
font-size: 11px
}
footer button {
background: transparent;
border: 1px solid #ffffff40;
border-radius: 30px;
color: inherit;
padding: 8px 16px
}
.touch {
position: absolute;
bottom: 80px;
left: 5%;
display: flex;
direction: ltr;
gap: 12px
}
.touch button {
width: 58px;
height: 58px;
border: 1px solid #ffffff50;
background: #123b2d80;
color: #fff8e9;
border-radius: 50%;
font-size: 25px;
touch-action: none;
user-select: none
}
.touch button:active {
background: #b8db85;
color: #123b2d
}
dialog {
width: min(480px, 92vw);
border: 1px solid #d9e6c4;
border-radius: 25px;
padding: 32px;
background: #fcf9ee;
color: #254334;
box-shadow: 0 24px 100px #0006
}
dialog::backdrop {
background: #071f1db0;
backdrop-filter: blur(7px)
}
dialog .eyebrow {
color: #64854d
}
dialog h2 {
font-size: 30px;
margin: 12px 0
}
dialog p {
font-size: 13px;
color: #687765
}
dialog small {
font-size: 11px;
color: #788370
}
#close {
position: absolute;
left: 20px;
top: 15px;
border: 0;
background: none;
font-size: 27px;
color: #486143
}
#products {
display: grid;
gap: 10px;
margin: 22px 0
}
#products button {
display: flex;
align-items: center;
gap: 14px;
text-align: right;
padding: 15px;
border-radius: 13px;
border: 1px solid #dfe5d2;
background: #f0f2e4;
color: #254334
}
#products button:disabled {
opacity: .45;
cursor: not-allowed
}
#products b {
flex: 1
}
#products .icon {
font-size: 30px
}
#shop-status {
min-height: 24px
}
#loading {
position: fixed;
inset: 0;
background: #163c2f;
display: grid;
place-items: center;
z-index: 9
}
@media (max-width: 650px) {
header {
top: 22px
}
.brand {
font-size: 22px
}
.intro {
top: 125px;
right: 6%
}
.intro h1 {
font-size: 48px
}
.mission {
top: auto;
bottom: 160px;
left: 5%;
width: 190px;
padding: 12px
}
.mission p {
margin: 8px 0;
font-size: 12px
}
#speech {
bottom: 31%;
width: max-content;
font-size: 12px
}
#interact {
bottom: 24%;
white-space: nowrap
}
footer p {
max-width: 170px;
font-size: 10px
}
.intro p {
font-size: 12px
}
.wallet {
padding: 6px 14px
}
.touch {
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}
/* 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}}
/* 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}
/* 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}
/* 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}
/* 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}}
+31
View File
@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {canWalk,movementDirection} from '../src/controls.js';
import {products,createState,purchase,INITIAL_BALANCE,formatHetoman} from '../src/game-state.js';
import {createFamily} from '../src/characters.js';
test('Walking remains enabled during dialogue; arrows up/down only zoom',()=>{
assert.ok(canWalk({chapterId:'bridge'}));assert.ok(canWalk({chapterId:'fish'}));
assert.equal(canWalk({busy:true}),false);assert.equal(canWalk({shopOpen:true}),false);
assert.equal(movementDirection(new Set(['arrowup']),new Set()),0);
assert.equal(movementDirection(new Set(['arrowright']),new Set()),1);
assert.equal(movementDirection(new Set(),new Set([-1])),-1);
});
test('Toman balance, charges and picnic reserve',()=>{
assert.equal(INITIAL_BALANCE,500);assert.match(formatHetoman(15),/هتومان/);
for(const p of products){const s=createState();assert.ok(purchase(s,p.id));assert.equal(s.balance,INITIAL_BALANCE-p.cost);}
const s={...createState(),balance:40};assert.equal(purchase(s,'channel-22'),null);assert.equal(s.balance,40);assert.ok(purchase(s,'channel-23'));s.busy=false;assert.ok(purchase(s,'channel-24'));assert.equal(s.balance,0);
});
test('Character transforms stay finite through walk, pickup and sitting',()=>{
const f=createFamily();for(const facing of [-Math.PI/2,Math.PI/2,Math.PI])for(const mode of ['walk','shop','sit']){
f.update({time:3,gait:2,dt:.016,moving:mode==='walk',facing,shopping:mode==='shop',finished:mode==='sit'});f.group.updateMatrixWorld(true);f.group.traverse(o=>assert.ok(o.matrixWorld.elements.every(Number.isFinite)));
}
});
test('Sixty channels, exactly 21–60 purchasable, Hetoman conversion',async()=>{
const {channels,productByCode,hetomanToRial}=await import('../src/game-state.js');
assert.equal(channels.length,60);assert.equal(products.length,40);
assert.equal(hetomanToRial(10),100000);
for(let n=1;n<=60;n++){assert.equal(Boolean(productByCode(String(n))),n>=21);}
assert.equal(productByCode('۲۲').channel,22);assert.equal(productByCode('٦٠').channel,60);assert.equal(productByCode('61'),null);
const s=createState();assert.equal(purchase(s,'channel-20'),null);assert.equal(s.balance,500);
});
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createState,productByCode} from '../src/game-state.js';
import {createPayment,beginPayment,swipePayment,cancelPayment} from '../src/payment.js';
test('Payment charges once, only after ready and explicit swipe',()=>{
const s=createState(),p=createPayment(),product=productByCode('21');assert.ok(product);
assert.ok(beginPayment(s,p,product));assert.equal(s.balance,500);assert.equal(s.basket.length,0);assert.equal(s.busy,true);
assert.equal(swipePayment(s,p),false);assert.equal(beginPayment(s,p,product),false);
p.phase='ready';assert.ok(swipePayment(s,p));assert.equal(s.balance,500-product.cost);assert.equal(s.basket.length,1);
assert.equal(swipePayment(s,p),false);assert.equal(cancelPayment(s,p),false);
});
test('Cancel or invalid payment never changes balance or basket',()=>{
const s=createState(),p=createPayment();assert.equal(beginPayment(s,p,productByCode('20')),false);
assert.ok(beginPayment(s,p,productByCode('60')));assert.ok(cancelPayment(s,p));assert.equal(s.balance,500);assert.equal(s.basket.length,0);assert.equal(s.busy,false);
});
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createStory,completeChapter,nextChapter,storyReady,routeOffset,discoveries} from '../src/story.js';
import {createState,purchase,readyForPicnic,productByCode} from '../src/game-state.js';
for(const route of ['sunny'])test(`Complete ${route} story and picnic`,()=>{
const story=createStory();assert.equal(nextChapter(story,-8),'morning');
for(const [id,x,choice] of [['morning',-8],['rabbit',-3,'watch'],['bridge',-2],['fish',0],['courage',3],['wind',12],['paper',14,'recover'],['fork',16,route],[route,19]]){
assert.equal(nextChapter(story,x),id);assert.ok(completeChapter(story,id,choice));assert.equal(completeChapter(story,id,choice),false);
}
assert.ok(storyReady(story));assert.equal(nextChapter(story,22),null);assert.ok(discoveries(story).length>=5);
assert.equal(routeOffset(story,16),0);assert.equal(routeOffset(story,21),0);assert.equal(Math.sign(routeOffset(story,18)),route==='sunny'?1:-1);
assert.equal(completeChapter(story,'summit','invalid'),false);assert.ok(completeChapter(story,'summit','gold'));assert.equal(story.sky,'gold');
const state=createState();assert.ok(purchase(state,productByCode('۲۲').id));state.busy=false;assert.ok(purchase(state,productByCode('۲۴').id));assert.ok(readyForPicnic(state));assert.equal(state.balance,440);
});
test('No early triggers, unknown choices, or leaked reset state',()=>{
const story=createStory();assert.equal(storyReady(story),false);completeChapter(story,'morning');assert.equal(nextChapter(story,-8),null);
assert.equal(completeChapter(story,'rabbit','wrong'),false);assert.equal(completeChapter(story,'unknown'),false);
assert.deepEqual(discoveries(createStory()),[]);assert.equal(routeOffset(createStory(),18),0);
});
test('Every story action exposes one fixed action instead of choices',async()=>{
const {chapters}=await import('../src/story.js');
for(const chapter of Object.values(chapters))assert.equal(chapter.choices,undefined);
for(const id of ['rabbit','paper','fork','summit']){const s=createStory();assert.equal(completeChapter(s,id,'wrong'),false);assert.ok(completeChapter(s,id,chapters[id].action.value));}
});