diff --git a/README.md b/README.md
index fa58be5..e5770d2 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ node --test tests/*.test.js
- `src/game-state.js`: قیمتهای هتومان، موجودی، اعتبارسنجی کد و خرید
- `src/story.js`: متنهای کوتاه، فصلها، انتخاب مسیر و کشفها
- `src/style.css`: چیدمان واکنشگرا و کادر سفید متن
-- `tests/`: آزمون هر دو مسیر داستان، حرکت، پول و تبدیلهای شخصیتها
+- `tests/`: آزمون مسیر داستان، حرکت، پول و تبدیلهای شخصیتها
قیمتها و موجودی فقط در ماژول game-state تعریف شدهاند. محاسبات با هتومان صحیح انجام میشود؛ رابط با ارقام فارسی نمایش میدهد.
@@ -51,3 +51,11 @@ node --test tests/*.test.js
## تعامل تکدکمهای
گزینههای چندانتخابی داستان حذف شدهاند. هر رویداد یک کار مشخص دارد: تماشای خرگوش، برداشتن نقاشی، ادامه در مسیر آفتابی و کامل کردن نقاشی با آسمان طلایی. برای انجام آن، کلید **F** یا همان دکمهٔ روی صفحه را بزنید. متنهای معمولی خودکار عوض میشوند و حرکت آزاد میماند. **E** تعامل با دستگاه و **C** کشیدن کارت است. انتخاب شمارهٔ محصول و دکمهٔ پرداخت در پنجرهٔ دستگاه باقی است.
+
+## سفر از خانه به کوه
+
+بازی با خروج خانواده از خانه شروع میشود. با F یا دکمهٔ روی صفحه سوار ماشین شوید؛ با W یا ↑ گاز بدهید، با S یا ↓ ترمز کنید و با A/D فرمان بدهید؛ پس از رسیدن به ورودی جنگل، در ورودی جنگل دوباره F را بزنید تا پیاده شوید. در مسیر جنگل، جمعی از دخترها و پسرها کنار آتش میرقصند و خانواده آدرس دستگاه وندینگ را از آنها میپرسد. پس از خرید نوشیدنی و خوراکی، مسیر را تا پیکنیک بالای کوه ادامه دهید.
+
+دوربین پیشفرض سومشخص است؛ با V یا دکمهٔ تغییر دوربین بین نمای اولشخص و سومشخص جابهجا شوید. برای نگاه به اطراف روی صحنه بکشید. W/S یا چپ/راست حرکت در مسیر و Shift دویدن است. این نسخه از مدلهای هندسی استفاده میکند و کیفیت گرافیکی یک بازی AAA ندارد.
+
+گفتوگوی هر رویداد هنگام رسیدن به همان نقطه نمایش داده میشود و تا پایان آن حرکت متوقف میماند. دوربین نزدیک دستگاه خودکار روی آن تمرکز میکند. خانواده پس از رسیدن به قله روی فرش مینشینند.
diff --git a/index.html b/index.html
index 5a3bac2..4f5dd2c 100644
--- a/index.html
+++ b/index.html
@@ -1 +1 @@
-
گردش سبز | روبات مارکت در حال آمادهسازی جنگل…
یک ماجراجویی کوچک، کنار هم تپهٔ آرزوها دستهای کوچک، کشفهای بزرگ.
بابا، بریم ببینیم اونجا چیه؟
روبات مارکت · ۶۰ کانال ۱۰ هتومان = ۱۰۰٬۰۰۰ ریالبرای خرید، کانال ۲۱ تا ۶۰ را انتخاب کن. E تعامل دفترچهٔ کشفها 📖 صدا: خاموش ♫ شروع دوباره ↻ راهنمای بازی ← → حرکت چپ و راست
↑ نزدیکتر شدن دوربین
↓ دورتر شدن دوربین
A D حرکت · E خرید
F انجام کار داستانی
C کشیدن کارت
حرکت و زوم: دکمه را نگه دارید. ↑ ← → ↓
× یک توقف خوشمزه چی میل داری؟ کد روی محصول را وارد کن و دکمهٔ تأیید را بزن.
واحد بازی: هتومان۱۰ هتومان = ۱۰۰٬۰۰۰ ریال کانالهای ۲۱ تا ۶۰ قابل خریدند؛ سایر کانالها در این بخش نمایش داده نمیشوند.
خرید نمایشی است و پرداخت واقعی ندارد. کارتخوان روبات مارکت
کشیدن کارت · C انصراف پرداخت فقط نمایشی است. یک خاطرهٔ سبز، کنار هم پیکنیک آماده است! 🌿
بستهها را در سطل بینداز ♻
یک گردش دیگر ↻ × دفترچهٔ کشفهای من هر قدم، یک خاطرهٔ تازه.
+گردش سبز | روبات مارکت در حال آمادهسازی جنگل…
یک ماجراجویی کوچک، کنار هم تپهٔ آرزوها دستهای کوچک، کشفهای بزرگ.
بابا، بریم ببینیم اونجا چیه؟
روبات مارکت · ۶۰ کانال ۱۰ هتومان = ۱۰۰٬۰۰۰ ریالبرای خرید، کانال ۲۱ تا ۶۰ را انتخاب کن. E تعامل دفترچهٔ کشفها 📖 صدا: خاموش ♫ شروع دوباره ↻ راهنمای بازی ← → حرکت در مسیر · W / S
↑ نزدیکتر شدن دوربین
↓ دورتر شدن دوربین
A D حرکت · E خرید
F انجام کار داستانی
C کشیدن کارت
Shift دویدن · V تغییر دوربین کشیدن روی صحنه: نگاه به اطراف ↑ ← → ↓
× یک توقف خوشمزه چی میل داری؟ کد روی محصول را وارد کن و دکمهٔ تأیید را بزن.
واحد بازی: هتومان۱۰ هتومان = ۱۰۰٬۰۰۰ ریال کانالهای ۲۱ تا ۶۰ قابل خریدند؛ سایر کانالها در این بخش نمایش داده نمیشوند.
خرید نمایشی است و پرداخت واقعی ندارد. کارتخوان روبات مارکت
کشیدن کارت · C انصراف پرداخت فقط نمایشی است. یک خاطرهٔ سبز، کنار هم پیکنیک آماده است! 🌿
بستهها را در سطل بینداز ♻
یک گردش دیگر ↻ × دفترچهٔ کشفهای من هر قدم، یک خاطرهٔ تازه.
diff --git a/src/characters.js b/src/characters.js
index 2facaa0..986eea1 100644
--- a/src/characters.js
+++ b/src/characters.js
@@ -102,9 +102,9 @@ export function createFamily() {
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.position.x = THREE.MathUtils.lerp(actor.root.position.x, finished ? (index === 0 ? -.55 : .55) : shopping ? (index === 0 ? -.55 : .25) : 0, blend);
+ actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.6 * actor.scale : 0, blend);
+ actor.root.position.z = finished ? 0 : 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;
@@ -115,9 +115,9 @@ export function createFamily() {
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.hip.rotation.x = finished ? -1.4 : 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;
+ leg.knee.rotation.x = finished ? 2.5 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05;
});
});
group.updateMatrixWorld(true);
diff --git a/src/journey.js b/src/journey.js
new file mode 100644
index 0000000..f5f565d
--- /dev/null
+++ b/src/journey.js
@@ -0,0 +1,24 @@
+export function createJourney() { return {phase: 'home', time: 0, distance: 0, speed: 0, lane: 0}; }
+export function advanceJourney(journey) {
+ const next = {home: 'boarding', entrance: 'walking'}[journey.phase];
+ if (!next) return false;
+ journey.phase = next; journey.time = 0; return true;
+}
+export function tickJourney(journey, dt, paused = false, input = {}) {
+ if (paused) return;
+ journey.time += dt;
+ if (journey.phase === 'boarding' && journey.time >= 2) { journey.phase = 'driving'; journey.time = 0; return; }
+ if (journey.phase === 'driving') {
+ const throttle = Math.max(-1, Math.min(1, input.throttle ?? 0));
+ journey.speed = Math.max(0, Math.min(6, journey.speed + (throttle > 0 ? 3.5 : throttle < 0 ? -7 : -2.5) * dt));
+ journey.distance = Math.min(21, journey.distance + journey.speed * dt);
+ journey.lane = Math.max(-.75, Math.min(.75, journey.lane + (input.steer ?? 0) * dt * journey.speed * .25));
+ if (journey.distance >= 21) { journey.phase = 'entrance'; journey.time = 0; journey.speed = 0; journey.lane = 0; }
+ }
+}
+export const journeyLabels = {
+ home: ['از خانه تا کوه', 'پدر: همهچی آمادهست؟ بیا سوار ماشین بشیم.', 'سوار ماشین شو · F'],
+ boarding: ['آغاز سفر', 'داریم سوار ماشین میشیم…', ''],
+ driving: ['جادهٔ جنگلی', 'با W یا ↑ گاز بده، با S یا ↓ ترمز کن و با A / D فرمان بده؛ تا ورودی جنگل رانندگی کن.', ''],
+ entrance: ['ورودی جنگل', 'پدر: رسیدیم. از اینجا پیاده میریم؛ بالای کوه پیکنیک میکنیم.', 'پیاده شو و وارد جنگل شو · F']
+};
diff --git a/src/main.js b/src/main.js
index 36a8727..b6741b8 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,3 +1,5 @@
+import {createJourney, advanceJourney, tickJourney, journeyLabels} from './journey.js';
+import {createJourneyScenery} from './scenery.js';
import * as THREE from 'three';
import './style.css';
import {createPayment, beginPayment, swipePayment, cancelPayment} from './payment.js';
@@ -23,7 +25,7 @@ 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);
+const camera = new THREE.PerspectiveCamera(65, 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);
@@ -103,6 +105,21 @@ for (let i = 0; i < 100; i++) {
ball(.065, material(i % 3 ? '#eadba0' : '#e4ae89'), scene, [x, .22, z]);
}
+const scenery = createJourneyScenery(scene, pathZ);
+let journey = createJourney(), firstPerson = false, viewYaw = 0, viewPitch = 0;
+const journeyPanel = document.createElement('section');
+journeyPanel.id = 'journey-panel';
+journeyPanel.innerHTML = 'FOREST ROAD · یک سفر کنار هم
';
+document.body.append(journeyPanel);
+journeyPanel.querySelector('button').onclick = () => { if (advanceJourney(journey)) {firstPerson=false;clearInput();syncHUD();} };
+const viewButton = document.createElement('button');
+viewButton.id = 'camera-view';viewButton.textContent = 'تغییر دوربین · V';document.body.append(viewButton);
+viewButton.onclick = () => {firstPerson = !firstPerson;};
+let dragging = false, dragX = 0, dragY = 0;
+canvas.addEventListener('pointerdown', e => {dragging=true;dragX=e.clientX;dragY=e.clientY;canvas.setPointerCapture(e.pointerId);});
+canvas.addEventListener('pointermove', e => {if(!dragging)return;viewYaw=THREE.MathUtils.clamp(viewYaw+(e.clientX-dragX)*.005,-1.3,1.3);viewPitch=THREE.MathUtils.clamp(viewPitch-(e.clientY-dragY)*.004,-.6,.6);dragX=e.clientX;dragY=e.clientY;});
+canvas.addEventListener('pointerup',()=>dragging=false);
+canvas.addEventListener('pointercancel',()=>dragging=false);
const characters = createFamily();
const family = characters.group;
scene.add(family);
@@ -113,7 +130,24 @@ 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]);
+const shell = material('#243c3c', {metalness:.55, roughness:.32});
+box(1.82, 2.96, 1.15, shell, machine, [0, 1.48, 0]);
+const bezel = material('#bbc6bd', {metalness:.75,roughness:.25});
+const led = new THREE.MeshBasicMaterial({color:'#c7f6dc'});
+for(const side of [-1,1]) {
+ box(.045,2.78,.04,bezel,machine,[side*.86,1.5,.6]);
+ box(.018,1.86,.025,led,machine,[-.13+side*.60,1.55,.86]);
+}
+box(1.72,.07,.04,bezel,machine,[0,2.91,.6]);
+box(1.72,.07,.04,bezel,machine,[0,.12,.6]);
+box(.3,1.45,.08,material('#142625'),machine,[.67,1.53,.6]);
+box(1.2,.04,.08,bezel,machine,[-.13,2.5,.7]);
+const displayCanvas=document.createElement('canvas');displayCanvas.width=256;displayCanvas.height=256;
+const displayCtx=displayCanvas.getContext('2d');displayCtx.fillStyle='#122d2c';displayCtx.fillRect(0,0,256,256);
+displayCtx.textAlign='center';displayCtx.fillStyle='#bcf2d5';displayCtx.font='bold 31px Tahoma';displayCtx.fillText('پرداخت',128,70);
+displayCtx.font='24px Tahoma';displayCtx.fillText('کارت بکشید',128,125);displayCtx.fillStyle='#85bbab';displayCtx.fillRect(44,170,168,4);
+mesh(new THREE.PlaneGeometry(.23,.3),new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(displayCanvas)}),machine,[.67,1.7,.669]);
+
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));
@@ -167,13 +201,13 @@ const label = document.createElement('canvas');
label.width = 512;
label.height = 128;
const ctx = label.getContext('2d');
-ctx.fillStyle = '#193c30';
+ctx.fillStyle = '#193332';
ctx.fillRect(0, 0, 512, 128);
ctx.fillStyle = '#d9efa7';
-ctx.font = 'bold 57px Tahoma';
+ctx.font = 'bold 52px Tahoma';
ctx.textAlign = 'center';
ctx.fillText('روبات مارکت', 256, 83);
-const sign = material('#ffffff', {map: new THREE.CanvasTexture(label), emissive: '#9cce91', emissiveIntensity: .18});
+const sign = new THREE.MeshBasicMaterial({color:'#ffffff', map: new THREE.CanvasTexture(label)});
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]);
@@ -310,6 +344,8 @@ function renderChapter() {
}
function performStoryAction() {
+ if(shop.open || notebook.open || ending.open || state.busy) return;
+ if (journey.phase !== 'walking') { if (advanceJourney(journey)) {firstPerson=false;clearInput();syncHUD();} return; }
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);
@@ -451,7 +487,12 @@ $('#sound').onclick = async () => {
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') ? 'یک خوراکی هم برای پیکنیک بردارید' : 'یک نوشیدنی و یک خوراکی تهیه کنید';
+ $('#mission').textContent = journey.phase !== 'walking' ? journeyLabels[journey.phase][1]
+ : state.finished ? 'به قله رسیدید؛ روی فرش استراحت کنید'
+ : chapterId ? chapters[chapterId].title
+ : !story.done.includes('camp') ? 'مسیر جنگل را تا جمع کنار آتش ادامه بدهید'
+ : !readyForPicnic(state) ? 'از دستگاه یک نوشیدنی و یک خوراکی بخرید'
+ : 'خرید آماده است؛ مسیر را تا فرش بالای قله ادامه بدهید';
}
function selectChannel(code) {
@@ -502,7 +543,7 @@ function deliver(p) {
}
function openShop() {
- if (Math.abs(x - stationX) < 2.5 && !shop.open && !notebook.open && !state.busy && !state.finished) {
+ if (!chapterId && journey.phase === 'walking' && Math.abs(x - stationX) < 2.5 && !shop.open && !notebook.open && !state.busy && !state.finished) {
pressed.clear();
held.clear();
$('#shop-status').textContent = 'بچه: خودم میتونم انتخاب کنم؟ پدر: حتماً! برای نوشیدنی و خوراکی پول کنار بگذار.';
@@ -574,6 +615,8 @@ $('#product-code').addEventListener('input', () => {
$('#interact').onclick = openShop;
$('#close').onclick = () => shop.close();
window.addEventListener('keydown', e => {
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
+ if(e.key.toLowerCase()==='v' && !e.repeat){firstPerson=!firstPerson;return;}
if (e.key.toLowerCase() === 'f') {
if (!e.repeat) performStoryAction();
return;
@@ -586,7 +629,7 @@ window.addEventListener('keydown', e => {
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();
+ if (e.key.toLowerCase() === 'e') { e.preventDefault(); openShop(); }
});
window.addEventListener('keyup', e => pressed.delete(e.key.toLowerCase()));
@@ -609,6 +652,8 @@ for (const [id, dir] of [['back', -1], ['forward', 1], ['zoom-in', 'in'], ['zoom
}
function reset() {
+ journey = createJourney(); firstPerson=false;viewYaw=0;viewPitch=0;
+ camera.position.set(-39,3.5,-6);look.set(-32,1,0);
payment = createPayment();
paymentTime = 0;
$('#payment-panel').hidden = true;
@@ -630,6 +675,7 @@ function reset() {
encounterActive = false;
facing = Math.PI / 2;
zoomDistance = 1;
+ picnicFood.traverse(node => { if (node.isMesh) node.geometry.dispose(); });
picnicFood.clear();
clearInput();
if (shop.open) shop.close();
@@ -686,7 +732,8 @@ function resize() {
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);
+camera.position.set(-39,3.5,-6);
+look.set(-32,1,0);
let last = performance.now();
function frame(now) {
@@ -694,10 +741,23 @@ function frame(now) {
const dt = Math.min((now - last) / 1000, .1);
last = now;
elapsed += dt;
- const direction = movementDirection(pressed, held);
+ const paused = shop.open || notebook.open || ending.open || document.hidden;
+ tickJourney(journey, dt, paused, {throttle:Number(pressed.has('w') || pressed.has('arrowup') || held.has(1))-Number(pressed.has('s') || pressed.has('arrowdown') || held.has(-1)),steer:Number(pressed.has('d') || pressed.has('arrowright') || held.has('out'))-Number(pressed.has('a') || pressed.has('arrowleft') || held.has('in'))});
+ scenery.update(elapsed, journey.phase, journey.distance/21, journey.lane);
+ const walking = journey.phase === 'walking';
+ document.body.classList.toggle('on-journey', !walking);
+ document.body.classList.toggle('driving', journey.phase === 'driving');
+ $('#forward').textContent = journey.phase === 'driving' ? 'گاز' : '→';
+ $('#back').textContent = journey.phase === 'driving' ? 'ترمز' : '←';
+ $('#zoom-in').textContent = journey.phase === 'driving' ? '↶' : '↑';
+ $('#zoom-out').textContent = journey.phase === 'driving' ? '↷' : '↓';
+ for (const [id, driveLabel, walkLabel] of [['forward','گاز','حرکت به جلو'],['back','ترمز','حرکت به عقب'],['zoom-in','فرمان چپ','زوم نزدیک'],['zoom-out','فرمان راست','زوم دور']]) $('#'+id).setAttribute('aria-label', journey.phase === 'driving' ? driveLabel : walkLabel);
+ journeyPanel.hidden = walking;
+ if(!walking){const [title,line,action]=journeyLabels[journey.phase];journeyPanel.querySelector('h2').textContent=title;journeyPanel.querySelector('p').textContent=line;const button=journeyPanel.querySelector('button');button.textContent=action;button.hidden=!action;if(journey.phase==='driving')journeyPanel.querySelector('h2').textContent=`جادهٔ جنگلی · ${Math.round(journey.distance/21*100).toLocaleString('fa-IR')}٪ · ${Math.round(journey.speed*12).toLocaleString('fa-IR')} کیلومتر/ساعت`;}
+ const direction = movementDirection(pressed, held) || (Number(pressed.has('w'))-Number(pressed.has('s')));
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 (walking && next && !chapterId && !shop.open && !ending.open && !notebook.open && !state.busy && !state.finished) showChapter(next);
+ if (chapterId && !state.busy && !shop.open && !notebook.open && !ending.open && !document.hidden) {
const chapter = chapters[chapterId], lastLine = chapterLine === chapter.lines.length - 1;
if (!(lastLine && chapter.action)) {
chapterTimer -= dt;
@@ -710,14 +770,14 @@ function frame(now) {
}
}
encounterActive = chapterId === 'rabbit';
- moving = canWalk({
+ moving = walking && canWalk({
shopOpen: shop.open,
notebookOpen: notebook.open,
endingOpen: ending.open,
- busy: state.busy,
+ busy: state.busy || Boolean(chapterId),
finished: state.finished
}) ? direction : 0;
- x = THREE.MathUtils.clamp(x + moving * 2.2 * dt, -9, 22);
+ x = THREE.MathUtils.clamp(x + moving * (pressed.has('shift') ? 3.7 : 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;
@@ -725,7 +785,7 @@ function frame(now) {
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 = state.finished ? 0 : 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({
@@ -739,14 +799,14 @@ function frame(now) {
finished: state.finished,
watchRabbit: encounterActive
});
- if (state.finished) family.position.x = 22.7;
+ if (state.finished) {family.position.copy(picnic.position);family.rotation.y=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);
- camTarget.set(x + 7, state.finished ? 8 : innerWidth < 650 ? 12 : 9, innerWidth < 650 ? 19 : 13);
+ camTarget.set(x - 4.5, pathHeight(x) + (innerWidth < 650 ? 4 : 3), pathZ(x - 4.5) + 2);
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 (walking && (shopping || Math.abs(x-stationX)<3.2)) {
+ camTarget.set(stationX + 2.6, 2.5, stationZ + (innerWidth < 650 ? 6.5 : 5));
+ lookTarget.set(stationX, 1.5, stationZ + .6);
}
if (encounterActive) {
camTarget.set(x + 5, 5.5, pathZ(x) + 9);
@@ -757,6 +817,21 @@ function frame(now) {
zoomDistance = THREE.MathUtils.clamp(zoomDistance + zoomInput * dt * .45, .6, 1.6);
}
camTarget.sub(lookTarget).multiplyScalar(zoomDistance).add(lookTarget);
+ if(walking && Math.abs(x-stationX)>=3.2 && firstPerson && !shopping && !state.finished && !encounterActive){
+ const bob=moving?Math.sin(gait*2)*.035:0;
+ camTarget.set(x-.35,1.7+pathHeight(x)+bob,pathZ(x)-.7);
+ lookTarget.set(x+5*Math.cos(viewYaw),1.65+pathHeight(x)+viewPitch*4,pathZ(x)+5*Math.sin(viewYaw));
+ }
+ if(!walking){
+ const cx=scenery.car.position.x;
+ family.position.set(journey.phase==='home'?-35:journey.phase==='boarding'?-35+Math.min(1,journey.time/2)*3:-10,0,journey.phase==='home'?4.2-Math.min(1,journey.time/2)*1.2:0);
+ if(journey.phase==='boarding')characters.update({dt,time:elapsed,gait:elapsed*6,moving:true,facing:Math.PI/2});
+ camTarget.set(cx-6,3.2,-7);lookTarget.set(cx,1,0);
+ if(journey.phase==='driving' || journey.phase==='entrance'){camTarget.set(cx-5,3.2,scenery.car.position.z-5);lookTarget.set(cx+3,1,scenery.car.position.z);}
+ }
+ family.visible = journey.phase !== 'driving' && !(journey.phase === 'boarding' && journey.time > 1.8) && !(walking && firstPerson && !shopping && !state.finished && !encounterActive);
+ if(chapterId==='camp' && walking){camTarget.set(1,2.4,-1);lookTarget.set(4,1,4.4);family.visible=true;}
+ if(state.finished){camTarget.set(25.5,5.1,picnic.position.z+6);lookTarget.copy(picnic.position).add(new THREE.Vector3(0,.6,0));}
if (['approaching', 'ready', 'approved'].includes(payment.phase)) {
const reader = new THREE.Vector3(.67, 1.7, .68);
machine.localToWorld(reader);
@@ -772,7 +847,7 @@ function frame(now) {
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;
+ const promptVisible = walking && 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`;
@@ -782,11 +857,19 @@ function frame(now) {
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();
+ syncHUD();
+ $('#speech').hidden = !walking || Boolean(chapterId) || shop.open || notebook.open || ending.open;
+ $('#speech').textContent = state.finished ? (state.recycled ? 'بچه: جنگل رو تمیز گذاشتیم؛ چه روز خوبی!' : 'بچه: رسیدیم نوک قله؛ روی فرش کنار هم نشستیم.')
+ : payment.phase !== 'idle' ? $('#payment-result').textContent
+ : deliveryTime > 0 ? 'پدر: محصول در حال تحویل است؛ کمی صبر کن.'
+ : near ? (readyForPicnic(state) ? 'پدر: خرید کامل شد؛ حالا به سمت قله برویم.' : 'پدر: این هم دستگاه؛ با E نوشیدنی و خوراکی انتخاب کن.')
+ : x > 20 && !readyForPicnic(state) ? 'پدر: هنوز نوشیدنی و خوراکی نداریم؛ به دستگاه برگردیم.'
+ : x > 16 ? 'پدر: مسیر قله را ادامه بده؛ فرش بالای کوه آماده است.'
+ : x > 11 ? 'بچه: از مسیر جنگل به سمت بالای کوه میریم.'
+ : x > 2.7 && story.done.includes('camp') ? 'بچه: دوستان گفتند دستگاه کمی جلوتره؛ بریم خرید کنیم.'
+ : x > -2.2 ? 'پدر: از روی پل عبور کنیم و مسیر جنگل را ادامه بدهیم.'
+ : 'بچه: از ورودی جنگل به سمت مسیر پیادهروی بریم.';
+ if (!shop.open && !notebook.open && !ending.open && x > 21 && storyReady(story) && readyForPicnic(state) && !state.busy && !state.finished) finish();
if (payment.phase !== 'idle') {
paymentTime += dt;
if (payment.phase === 'approaching' && paymentTime > 1.2) {
diff --git a/src/scenery.js b/src/scenery.js
new file mode 100644
index 0000000..2a0ba9e
--- /dev/null
+++ b/src/scenery.js
@@ -0,0 +1,59 @@
+import * as THREE from 'three';
+
+export function createJourneyScenery(scene, pathZ) {
+ const mat = c => new THREE.MeshStandardMaterial({color:c, roughness:.7});
+ const wood=mat('#705340'), stone=mat('#a9a393'), black=mat('#172126'), skin=mat('#dca57e');
+ function part(parent, geo, material, x,y,z) {
+ const m=new THREE.Mesh(geo,material);m.position.set(x,y,z);m.castShadow=true;m.receiveShadow=true;parent.add(m);return m;
+ }
+ const box=(p,w,h,d,m,x,y,z)=>part(p,new THREE.BoxGeometry(w,h,d),m,x,y,z);
+ const home=new THREE.Group();home.position.set(-35,0,7);scene.add(home);
+ box(home,8,4,6,mat('#d2c1a3'),0,2,0);
+ const roof=part(home,new THREE.ConeGeometry(6.5,2,4),mat('#6d493d'),0,5,0);roof.rotation.y=Math.PI/4;
+ box(home,1.3,2.6,.15,wood,0,1.3,-3.08);
+ for(const x of [-2.5,2.5]){box(home,1.6,1.4,.12,mat('#8eafb3'),x,2.4,-3.08);box(home,.08,1.4,.15,wood,x,2.4,-3.16);}
+ box(scene,31,.035,5,mat('#555957'),-22,.02,0);
+ for(let x=-36;x<-8;x+=3)box(scene,1.5,.045,.12,mat('#ddd0a3'),x,.04,0);
+ const car=new THREE.Group();scene.add(car);
+ const paint=mat('#bd703c'),glass=mat('#72989e');
+ box(car,4.4,.65,1.9,paint,0,.8,0);box(car,2.3,.8,1.75,glass,-.15,1.5,0);box(car,2.5,.12,1.9,paint,-.15,1.96,0);
+ for(const x of [-1.35,1.35])for(const z of [-1,1]){
+ const wheel=part(car,new THREE.CylinderGeometry(.42,.42,.22,16),black,x,.48,z);wheel.rotation.x=Math.PI/2;
+ box(car,.09,.2,.45,mat(x>0?'#fff1c0':'#b34232'),x>0?2.23:-2.23,.9,z*.65);
+ }
+ box(car,.1,.5,1.7,black,-1.32,1.45,0);
+ box(scene,5,.04,3,stone,-11,.025,-4);
+ const gate=new THREE.Group();gate.position.set(-8.5,0,pathZ(-8.5));scene.add(gate);
+ for(const z of [-2.5,2.5])box(gate,.3,3.7,.3,wood,0,1.85,z);
+ box(gate,.4,.65,5.8,wood,0,3.4,0);
+ const signCanvas=document.createElement('canvas');signCanvas.width=512;signCanvas.height=128;
+ const ctx=signCanvas.getContext('2d');ctx.fillStyle='#24392d';ctx.fillRect(0,0,512,128);ctx.fillStyle='#f2dfae';ctx.textAlign='center';ctx.font='bold 42px Tahoma';ctx.fillText('ورودی جنگل · تپهٔ آرزوها',256,80);
+ const sign=part(gate,new THREE.PlaneGeometry(4,.9),new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(signCanvas),side:THREE.DoubleSide}),.25,3.4,0);sign.rotation.y=Math.PI/2;
+ const camp=new THREE.Group();camp.position.set(4,0,4.4);scene.add(camp);
+ for(let i=0;i<12;i++){const a=i*Math.PI/6;part(camp,new THREE.DodecahedronGeometry(.24),stone,Math.cos(a)*.9,.16,Math.sin(a)*.9);}
+ for(let i=0;i<4;i++){const log=box(camp,1.4,.16,.17,wood,0,.23,0);log.rotation.y=i*Math.PI/4;}
+ const flames=[];
+ for(let i=0;i<7;i++){
+ const flame=part(camp,new THREE.ConeGeometry(.18+(i%2)*.1,.8,7),new THREE.MeshBasicMaterial({color:i%2?'#ffb32f':'#f56528',transparent:true,opacity:.85}),Math.sin(i*2)*.3,.65,Math.cos(i*2)*.3);flames.push(flame);
+ }
+ const light=new THREE.PointLight('#ff993d',8,10);light.position.y=1.2;camp.add(light);
+ const dancers=[];
+ for(let i=0;i<6;i++){
+ const root=new THREE.Group(), a=i*Math.PI/3;root.position.set(Math.cos(a)*2.2,0,Math.sin(a)*2.2);root.rotation.y=-a-Math.PI/2;camp.add(root);
+ const female=i%2===0, cloth=mat(['#ae684a','#698b84','#c29d59','#75829a','#985b69','#6f8158'][i]);
+ part(root,new THREE.CylinderGeometry(.23,.20,.6,10),cloth,0,1.15,0);
+ part(root,new THREE.SphereGeometry(.22,12,10),skin,0,1.7,0);
+ part(root,new THREE.SphereGeometry(.235,12,10),mat('#322923'),0,1.8,-.045).scale.set(1,female?1.4:.65,1);
+ if(female)part(root,new THREE.CylinderGeometry(.23,.4,.5,12),cloth,0,.8,0);
+ for(const side of [-1,1])box(root,.14,.65,.17,black,side*.15,.4,0);
+ const arms=[];
+ for(const side of [-1,1]){const arm=new THREE.Group();arm.position.set(side*.27,1.4,0);root.add(arm);box(arm,.13,.6,.14,cloth,0,-.25,0);arms.push(arm);}
+ const cup=part(arms[0],new THREE.CylinderGeometry(.07,.06,.2,10),mat('#e6b853'),0,-.58,0);
+ dancers.push({root,arms,a});
+ }
+ return {car, update(time, phase, progress=0, lane=0) {
+ car.position.set(phase==='home'||phase==='boarding'?-32:phase==='driving'?-32+progress*21:-11,.05, ['entrance','walking'].includes(phase) ? -4 : -.3+lane-3.7*Math.pow(progress,6));
+ flames.forEach((f,i)=>{f.scale.y=.7+Math.sin(time*11+i)*.25;f.rotation.y=time+i;});light.intensity=7+Math.sin(time*17);
+ dancers.forEach(({root,arms,a},i)=>{root.position.y=Math.abs(Math.sin(time*3+i))*.12;root.rotation.z=Math.sin(time*3+i)*.09;arms.forEach((arm,j)=>{arm.rotation.z=(j?1:-1)*(.7+Math.sin(time*3+a+j)*.5);arm.rotation.x=Math.cos(time*2+i)*.3;});});
+ }};
+}
diff --git a/src/story.js b/src/story.js
index 9c364a2..115e339 100644
--- a/src/story.js
+++ b/src/story.js
@@ -1,5 +1,5 @@
export const chapters = {
- morning: {title: 'تپهٔ آرزوها', lines: ['پدر: بریم تپه؟ امروز راه رو با هم کشف میکنیم.']},
+ morning: {title: 'ورود به جنگل', lines: ['پدر: ماشین همینجا میمونه. پیاده میریم تا بالای کوه.']},
rabbit: {
title: 'یک دوست کوچک',
lines: ['بچه: یه خرگوش! آروم نگاهش کنیم؟'],
@@ -7,6 +7,7 @@ export const chapters = {
},
bridge: {title: 'روی پل', lines: ['پدر: دستم رو بگیر؛ با هم رد میشیم.']},
fish: {title: 'زیر پل', lines: ['بچه: ماهیها رو ببین!']},
+ camp: {title: 'دور آتش', lines: ['بچه: بابا! اون دخترها و پسرها دور آتیش میرقصن. نوشیدنیهاشون رو از کجا گرفتن؟', 'پدر: بریم بپرسیم. سلام! نوشیدنی رو از کجا خریدین؟', 'یکی از دوستان: سلام! کمی جلوتر یه دستگاه روبات مارکت هست؛ نوشیدنی و خوراکی داره.', 'بچه: پس ما هم از همونجا برای پیکنیک خرید کنیم!'], action: {value: 'ask', label: 'رفتن به دستگاه وندینگ'}},
courage: {title: 'اولین خرید من', lines: ['پدر: حالا برای پیکنیک خرید کنیم.']},
wind: {title: 'باد و نقاشی', lines: ['بچه: نقاشیم رفت کنار بوته!']},
paper: {
@@ -45,7 +46,7 @@ export function completeChapter(story, id, choice) {
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]]) {
+ for (const [id, threshold] of [['rabbit', -3.7], ['bridge', -2.2], ['fish', 0], ['camp', 2.7], ['courage', 6.5], ['wind', 12], ['paper', 14], ['fork', 16]]) {
if (!done(id)) return x >= threshold ? id : null;
}
if (!done(story.route)) return x >= 19 ? story.route : null;
@@ -53,7 +54,7 @@ export function nextChapter(story, x) {
}
export function storyReady(story) {
- return ['morning', 'rabbit', 'bridge', 'fish', 'courage', 'wind', 'paper', 'fork', story.route].every(id => story.done.includes(id));
+ return ['morning', 'rabbit', 'bridge', 'fish', 'camp', 'courage', 'wind', 'paper', 'fork', story.route].every(id => story.done.includes(id));
}
export function routeOffset(story, x) {
@@ -65,6 +66,7 @@ export function routeOffset(story, x) {
export function discoveries(story) {
const entries = [];
if (story.done.includes('rabbit')) entries.push(story.rabbitChoice === 'sketch' ? '🐇 نقاشی خرگوش' : '🐇 تماشای آرام خرگوش');
+ if (story.done.includes('camp')) entries.push('🔥 دوستان کنار آتش و آدرس وندینگ');
if (story.done.includes('fish')) entries.push('🐟 ماهیهای زیر پل');
if (story.done.includes('courage')) entries.push('🌉 پل شجاعت');
if (story.done.includes('paper')) entries.push('📄 نقاشی بازیابیشده');
diff --git a/src/style.css b/src/style.css
index d2bae8d..d8d2aba 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1379,3 +1379,24 @@ dialog p, dialog small {
height: 32px
}
}
+
+/* Close camera adventure HUD and journey cinematics. */
+#journey-panel {position:fixed;z-index:12;bottom:12%;left:50%;transform:translateX(-50%);width:min(560px,90vw);padding:24px 30px;background:linear-gradient(135deg,#10231ff2,#142c27db);border:1px solid #d8c48b66;border-radius:8px;box-shadow:0 16px 80px #0008;text-align:right;color:#f4e7cb;backdrop-filter:blur(14px)}
+#journey-panel[hidden]{display:none}
+#journey-panel small{font-size:11px;letter-spacing:2px;color:#d8bb7c}
+#journey-panel h2{margin:12px 0;font-size:28px}
+#journey-panel p{line-height:1.9}
+#journey-panel button{background:#c9b27d;color:#15241e;border:0;border-radius:4px;padding:12px 24px;font-weight:700}
+#camera-view{position:fixed;top:105px;left:5%;z-index:8;padding:9px 15px;border:1px solid #e7dbb84d;border-radius:4px;background:#142720c9;color:#eee4cf}
+.intro{pointer-events:none;opacity:.65}
+@media(max-width:650px){#camera-view{top:85px;font-size:11px}.intro{display:none}#journey-panel{bottom:22%;padding:16px}#journey-panel h2{font-size:23px}}
+
+.on-journey .intro,.on-journey .mission,.on-journey #speech,.on-journey .touch,.on-journey #controls-guide,.on-journey #camera-view{display:none}
+
+body.driving .touch{display:grid;z-index:16}
+body.driving #journey-panel{bottom:auto;top:115px;padding:12px 20px;pointer-events:none;width:min(520px,85vw)}
+body.driving #journey-panel h2{font-size:20px;margin:6px 0}
+body.driving #journey-panel p{font-size:12px;margin:6px 0}
+@media(max-width:650px){body.driving #journey-panel{top:90px}body.driving #journey-panel small{display:none}}
+
+.intro{display:none}
diff --git a/tests/journey.test.js b/tests/journey.test.js
new file mode 100644
index 0000000..802a59e
--- /dev/null
+++ b/tests/journey.test.js
@@ -0,0 +1,22 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {createJourney, advanceJourney, tickJourney} from '../src/journey.js';
+
+test('Driving requires player input and exit requires F at the entrance', () => {
+ const j=createJourney();tickJourney(j,20);assert.equal(j.phase,'home');
+ assert.ok(advanceJourney(j));tickJourney(j,2,true);assert.equal(j.phase,'boarding');
+ tickJourney(j,2);assert.equal(j.phase,'driving');
+ tickJourney(j,30);assert.equal(j.distance,0);assert.equal(j.phase,'driving');assert.equal(advanceJourney(j),false);
+ for(let i=0;i<200;i++)tickJourney(j,.05,false,{throttle:1});
+ assert.equal(j.phase,'entrance');assert.equal(j.distance,21);assert.equal(j.speed,0);
+ tickJourney(j,20);assert.equal(j.phase,'entrance');assert.ok(advanceJourney(j));assert.equal(j.phase,'walking');
+ assert.equal(advanceJourney(j),false);assert.equal(createJourney().distance,0);
+});
+test('Brake, steering bounds and pause work while driving', () => {
+ const j=createJourney();advanceJourney(j);tickJourney(j,2);
+ for(let i=0;i<20;i++)tickJourney(j,.05,false,{throttle:1,steer:1});
+ assert.ok(j.speed>0);assert.ok(j.lane>0 && j.lane<=.75);
+ const snapshot={...j};tickJourney(j,1,true,{throttle:1});assert.deepEqual(j,snapshot);
+ for(let i=0;i<30;i++)tickJourney(j,.05,false,{throttle:-1});
+ assert.equal(j.speed,0);assert.equal(j.phase,'driving');
+});
diff --git a/tests/story.test.js b/tests/story.test.js
index d45a4be..be5bf11 100644
--- a/tests/story.test.js
+++ b/tests/story.test.js
@@ -4,7 +4,7 @@ import {createStory,completeChapter,nextChapter,storyReady,routeOffset,discoveri
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]]){
+ for(const [id,x,choice] of [['morning',-8],['rabbit',-3,'watch'],['bridge',-2],['fish',0],['camp',3,'ask'],['courage',7],['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);
@@ -21,5 +21,5 @@ test('No early triggers, unknown choices, or leaked reset state',()=>{
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));}
+ for(const id of ['rabbit','paper','fork','summit','camp']){const s=createStory();assert.equal(completeChapter(s,id,'wrong'),false);assert.ok(completeChapter(s,id,chapters[id].action.value));}
});