diff --git a/src/journey.js b/src/journey.js index f5f565d..4031859 100644 --- a/src/journey.js +++ b/src/journey.js @@ -1,21 +1,37 @@ -export function createJourney() { return {phase: 'home', time: 0, distance: 0, speed: 0, lane: 0}; } +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; + 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 === '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; } + if (journey.distance >= 21) { + journey.phase = 'entrance'; + journey.time = 0; + journey.speed = 0; + journey.lane = 0; + } } } + export const journeyLabels = { home: ['از خانه تا کوه', 'پدر: همهچی آمادهست؟ بیا سوار ماشین بشیم.', 'سوار ماشین شو · F'], boarding: ['آغاز سفر', 'داریم سوار ماشین میشیم…', ''], diff --git a/src/main.js b/src/main.js index b6741b8..a88a38a 100644 --- a/src/main.js +++ b/src/main.js @@ -111,15 +111,36 @@ 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();} }; +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;}; +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); +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); @@ -130,23 +151,33 @@ const machine = new THREE.Group(); machine.position.set(stationX, 0, stationZ); machine.rotation.y = .13; scene.add(machine); -const shell = material('#243c3c', {metalness:.55, roughness:.32}); +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]); +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.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]); @@ -207,7 +238,7 @@ ctx.fillStyle = '#d9efa7'; ctx.font = 'bold 52px Tahoma'; ctx.textAlign = 'center'; ctx.fillText('روبات مارکت', 256, 83); -const sign = new THREE.MeshBasicMaterial({color:'#ffffff', map: new THREE.CanvasTexture(label)}); +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]); @@ -344,8 +375,15 @@ 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 (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); @@ -489,10 +527,10 @@ function syncHUD() { $('#basket').textContent = state.basket.length ? '🧺 ' + state.basket.map(p => p.icon).join(' ') : '🧺 سبد پیکنیک خالی است'; $('#mission').textContent = journey.phase !== 'walking' ? journeyLabels[journey.phase][1] : state.finished ? 'به قله رسیدید؛ روی فرش استراحت کنید' - : chapterId ? chapters[chapterId].title - : !story.done.includes('camp') ? 'مسیر جنگل را تا جمع کنار آتش ادامه بدهید' - : !readyForPicnic(state) ? 'از دستگاه یک نوشیدنی و یک خوراکی بخرید' - : 'خرید آماده است؛ مسیر را تا فرش بالای قله ادامه بدهید'; + : chapterId ? chapters[chapterId].title + : !story.done.includes('camp') ? 'مسیر جنگل را تا جمع کنار آتش ادامه بدهید' + : !readyForPicnic(state) ? 'از دستگاه یک نوشیدنی و یک خوراکی بخرید' + : 'خرید آماده است؛ مسیر را تا فرش بالای قله ادامه بدهید'; } function selectChannel(code) { @@ -616,7 +654,10 @@ $('#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() === 'v' && !e.repeat) { + firstPerson = !firstPerson; + return; + } if (e.key.toLowerCase() === 'f') { if (!e.repeat) performStoryAction(); return; @@ -629,7 +670,10 @@ 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') { e.preventDefault(); openShop(); } + if (e.key.toLowerCase() === 'e') { + e.preventDefault(); + openShop(); + } }); window.addEventListener('keyup', e => pressed.delete(e.key.toLowerCase())); @@ -652,8 +696,12 @@ 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); + 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; @@ -675,7 +723,9 @@ function reset() { encounterActive = false; facing = Math.PI / 2; zoomDistance = 1; - picnicFood.traverse(node => { if (node.isMesh) node.geometry.dispose(); }); + picnicFood.traverse(node => { + if (node.isMesh) node.geometry.dispose(); + }); picnicFood.clear(); clearInput(); if (shop.open) shop.close(); @@ -732,8 +782,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(-39,3.5,-6); -look.set(-32,1,0); +camera.position.set(-39, 3.5, -6); +look.set(-32, 1, 0); let last = performance.now(); function frame(now) { @@ -742,8 +792,11 @@ function frame(now) { last = now; elapsed += dt; 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); + 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'); @@ -751,10 +804,18 @@ function frame(now) { $('#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); + 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'))); + 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 (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) { @@ -799,12 +860,15 @@ function frame(now) { finished: state.finished, watchRabbit: encounterActive }); - if (state.finished) {family.position.copy(picnic.position);family.rotation.y=0;} + 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 - 4.5, pathHeight(x) + (innerWidth < 650 ? 4 : 3), pathZ(x - 4.5) + 2); lookTarget.set(x + 1, .8 + pathHeight(x), pathZ(x)); - if (walking && (shopping || Math.abs(x-stationX)<3.2)) { + 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); } @@ -817,21 +881,38 @@ 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 && 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);} + 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 (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); @@ -861,14 +942,14 @@ function frame(now) { $('#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 ? 'پدر: از روی پل عبور کنیم و مسیر جنگل را ادامه بدهیم.' - : 'بچه: از ورودی جنگل به سمت مسیر پیادهروی بریم.'; + : 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; diff --git a/src/scenery.js b/src/scenery.js index 2a0ba9e..78f1cc8 100644 --- a/src/scenery.js +++ b/src/scenery.js @@ -1,59 +1,127 @@ 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 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); + + 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(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); + 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); } - 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}); + 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); } - 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;});}); - }}; + 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 115e339..48f4d44 100644 --- a/src/story.js +++ b/src/story.js @@ -7,7 +7,11 @@ export const chapters = { }, bridge: {title: 'روی پل', lines: ['پدر: دستم رو بگیر؛ با هم رد میشیم.']}, fish: {title: 'زیر پل', lines: ['بچه: ماهیها رو ببین!']}, - camp: {title: 'دور آتش', lines: ['بچه: بابا! اون دخترها و پسرها دور آتیش میرقصن. نوشیدنیهاشون رو از کجا گرفتن؟', 'پدر: بریم بپرسیم. سلام! نوشیدنی رو از کجا خریدین؟', 'یکی از دوستان: سلام! کمی جلوتر یه دستگاه روبات مارکت هست؛ نوشیدنی و خوراکی داره.', 'بچه: پس ما هم از همونجا برای پیکنیک خرید کنیم!'], action: {value: 'ask', label: 'رفتن به دستگاه وندینگ'}}, + camp: { + title: 'دور آتش', + lines: ['بچه: بابا! اون دخترها و پسرها دور آتیش میرقصن. نوشیدنیهاشون رو از کجا گرفتن؟', 'پدر: بریم بپرسیم. سلام! نوشیدنی رو از کجا خریدین؟', 'یکی از دوستان: سلام! کمی جلوتر یه دستگاه روبات مارکت هست؛ نوشیدنی و خوراکی داره.', 'بچه: پس ما هم از همونجا برای پیکنیک خرید کنیم!'], + action: {value: 'ask', label: 'رفتن به دستگاه وندینگ'} + }, courage: {title: 'اولین خرید من', lines: ['پدر: حالا برای پیکنیک خرید کنیم.']}, wind: {title: 'باد و نقاشی', lines: ['بچه: نقاشیم رفت کنار بوته!']}, paper: { diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..5e9c3a4 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,6 @@ +import {defineConfig} from 'vite'; + +export default defineConfig({ + // Relative assets also load under a GitHub Pages repository path. + base: './' +});