From 5857b11bd840dd06a8c54aee430a0cf18b193ad0 Mon Sep 17 00:00:00 2001 From: "hamed@B" Date: Tue, 20 Oct 2026 12:26:14 +0330 Subject: [PATCH] Add interactive picnic journey and synchronize gameplay stages --- README.md | 10 ++- index.html | 2 +- src/characters.js | 10 +-- src/journey.js | 24 ++++++++ src/main.js | 137 +++++++++++++++++++++++++++++++++--------- src/scenery.js | 59 ++++++++++++++++++ src/story.js | 8 ++- src/style.css | 21 +++++++ tests/journey.test.js | 22 +++++++ tests/story.test.js | 4 +- 10 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 src/journey.js create mode 100644 src/scenery.js create mode 100644 tests/journey.test.js 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 @@ -گردش سبز | روبات مارکت
در حال آماده‌سازی جنگل…
روباتمارکتROBOT MARKET · FOREST WALK
۵۰۰ هتومان
یک ماجراجویی کوچک، کنار هم

تپهٔ آرزوها

دست‌های کوچک، کشف‌های بزرگ.

بابا، بریم ببینیم اونجا چیه؟
یک توقف خوشمزه

چی میل داری؟

کد روی محصول را وارد کن و دکمهٔ تأیید را بزن.

واحد بازی: هتومان
۱۰ هتومان = ۱۰۰٬۰۰۰ ریال
کانال‌های ۲۱ تا ۶۰ قابل خریدند؛ سایر کانال‌ها در این بخش نمایش داده نمی‌شوند.

شمارهٔ کانال

کد + محصول فرضی

خرید نمایشی است و پرداخت واقعی ندارد.
یک خاطرهٔ سبز، کنار هم

پیک‌نیک آماده است! 🌿

دفترچهٔ کشف‌های من

هر قدم، یک خاطرهٔ تازه.

+گردش سبز | روبات مارکت
در حال آماده‌سازی جنگل…
روباتمارکتROBOT MARKET · FOREST WALK
۵۰۰ هتومان
یک ماجراجویی کوچک، کنار هم

تپهٔ آرزوها

دست‌های کوچک، کشف‌های بزرگ.

بابا، بریم ببینیم اونجا چیه؟
یک توقف خوشمزه

چی میل داری؟

کد روی محصول را وارد کن و دکمهٔ تأیید را بزن.

واحد بازی: هتومان
۱۰ هتومان = ۱۰۰٬۰۰۰ ریال
کانال‌های ۲۱ تا ۶۰ قابل خریدند؛ سایر کانال‌ها در این بخش نمایش داده نمی‌شوند.

شمارهٔ کانال

کد + محصول فرضی

خرید نمایشی است و پرداخت واقعی ندارد.
یک خاطرهٔ سبز، کنار هم

پیک‌نیک آماده است! 🌿

دفترچهٔ کشف‌های من

هر قدم، یک خاطرهٔ تازه.

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));} });