first commit
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Lightweight articulated characters; all coordinates below are in the family group.
|
||||
export function createFamily() {
|
||||
const group = new THREE.Group();
|
||||
const mat = color => new THREE.MeshStandardMaterial({color, roughness: .82});
|
||||
const skin = mat('#edbd98'), hair = mat('#49382d'), shoes = mat('#273c36');
|
||||
|
||||
function shape(parent, geometry, material, position) {
|
||||
const m = new THREE.Mesh(geometry, material);
|
||||
m.position.set(...position);
|
||||
m.castShadow = true;
|
||||
m.receiveShadow = true;
|
||||
parent.add(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function sphere(parent, r, material, pos, scale = [1, 1, 1]) {
|
||||
const m = shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
|
||||
m.scale.set(...scale);
|
||||
return m;
|
||||
}
|
||||
|
||||
function segment(parent, a, b, r, material) {
|
||||
const mesh = shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
|
||||
return {mesh, a, b};
|
||||
}
|
||||
|
||||
function updateSegment(bone, a, b) {
|
||||
const delta = b.clone().sub(a);
|
||||
bone.mesh.position.copy(a).add(b).multiplyScalar(.5);
|
||||
bone.mesh.scale.y = delta.length();
|
||||
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
|
||||
}
|
||||
|
||||
function makePerson(isChild, z) {
|
||||
const scale = isChild ? .69 : 1.05, root = new THREE.Group();
|
||||
root.position.z = z;
|
||||
group.add(root);
|
||||
const torso = new THREE.Group();
|
||||
root.add(torso);
|
||||
const cloth = mat(isChild ? '#d98150' : '#e4c695'), pants = mat(isChild ? '#527463' : '#496474');
|
||||
// Tapered jacket, hips and neck replace the single rounded toy-like body.
|
||||
shape(torso,new THREE.CylinderGeometry(.255*scale,.22*scale,.62*scale,16),cloth,[0,1.2*scale,0]).scale.z=.72;
|
||||
sphere(torso,.23,pants,[0,.88*scale,0],[scale,.55*scale,.72*scale]);
|
||||
shape(torso,new THREE.CylinderGeometry(.075*scale,.09*scale,.18*scale,12),skin,[0,1.59*scale,0]);
|
||||
const seam=shape(torso,new THREE.BoxGeometry(.014*scale,.5*scale,.014*scale),mat('#bda77f'),[0,1.2*scale,.19*scale]);
|
||||
for(const side of [-1,1]){
|
||||
const collar=shape(torso,new THREE.BoxGeometry(.12*scale,.09*scale,.025*scale),cloth,[side*.07*scale,1.49*scale,.15*scale]);collar.rotation.z=side*.4;
|
||||
}
|
||||
const head = new THREE.Group();
|
||||
head.position.y = 1.77 * scale;
|
||||
torso.add(head);
|
||||
head.scale.setScalar(isChild?.9:.77);
|
||||
const eyes=[];
|
||||
sphere(head, .27 * scale, skin, [0, 0, 0]);
|
||||
sphere(head, .275 * scale, hair, [0, .11 * scale, -.035 * scale], [1, .65, 1]);
|
||||
sphere(head, .058 * scale, skin, [0, -.02 * scale, .25 * scale]);
|
||||
for (const side of [-1, 1]) {
|
||||
sphere(head, .06 * scale, skin, [side * .26 * scale, 0, 0]);
|
||||
sphere(head,.037*scale,mat('#f6f1e7'),[side*.09*scale,.03*scale,.241*scale],[1,.65,.5]);
|
||||
const eye=sphere(head,.022*scale,shoes,[side*.09*scale,.03*scale,.258*scale],[.8,1,.45]);eyes.push(eye);
|
||||
const brow=shape(head,new THREE.BoxGeometry(.075*scale,.015*scale,.015*scale),hair,[side*.09*scale,.087*scale,.237*scale]);brow.rotation.z=side*-.08;
|
||||
sphere(head, .04 * scale, mat('#d79379'), [side * .15 * scale, -.055 * scale, .215 * scale]);
|
||||
}
|
||||
const smile = shape(head, new THREE.TorusGeometry(.068 * scale, .012 * scale, 6, 14, Math.PI), shoes, [0, -.075 * scale, .252 * scale]);
|
||||
smile.rotation.z = Math.PI;
|
||||
if (isChild) {
|
||||
sphere(head, .29 * scale, mat('#e8b857'), [0, .18 * scale, 0], [1, .5, 1]);
|
||||
shape(head, new THREE.BoxGeometry(.55 * scale, .04 * scale, .34 * scale), mat('#e8b857'), [0, .13 * scale, .12 * scale]);
|
||||
} else {
|
||||
sphere(torso, .22, mat('#a77950'), [0, 1.2 * scale, -.24 * scale], [1, 1.2, .5]);
|
||||
}
|
||||
const legs = [-1, 1].map(side => {
|
||||
const hip = new THREE.Group();
|
||||
hip.position.set(side * .15 * scale, .83 * scale, 0);
|
||||
root.add(hip);
|
||||
shape(hip, new THREE.CapsuleGeometry(.09 * scale, .23 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
|
||||
const knee = new THREE.Group();
|
||||
knee.position.y = -.34 * scale;
|
||||
hip.add(knee);
|
||||
shape(knee, new THREE.CapsuleGeometry(.08 * scale, .25 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
|
||||
const foot = shape(knee, new THREE.SphereGeometry(.12 * scale, 12, 8), shoes, [0, -.32 * scale, .065 * scale]);
|
||||
foot.scale.set(.85, .65, 1.5);
|
||||
return {hip, knee, foot};
|
||||
});
|
||||
const arms = [-1, 1].map(() => ({
|
||||
upper: segment(group, null, null, .072 * scale, cloth),
|
||||
lower: segment(group, null, null, .061 * scale, skin),
|
||||
hand: sphere(group, .075 * scale, skin, [0, 0, 0])
|
||||
}));
|
||||
return {root, torso, head, legs, arms, scale, z,eyes};
|
||||
}
|
||||
|
||||
const father = makePerson(false, -.45), child = makePerson(true, .45);
|
||||
|
||||
|
||||
function update({time, gait, moving, facing, shopping, delivery, finished, watchRabbit,dt=1/60}) {
|
||||
const actors = [father, child];
|
||||
actors.forEach((actor, index) => {
|
||||
const blend=1-Math.exp(-dt*7);
|
||||
actor.root.position.x=THREE.MathUtils.lerp(actor.root.position.x,shopping?(index===0?-.55:.25):0,blend);
|
||||
actor.root.position.y=THREE.MathUtils.lerp(actor.root.position.y,finished?-.32:0,blend);
|
||||
actor.root.position.z=actor.z;
|
||||
actor.root.rotation.y = facing;
|
||||
const bob = moving ? Math.abs(Math.sin(gait)) * .022 : Math.sin(time * 2) * .006;
|
||||
actor.torso.position.y = bob;
|
||||
actor.torso.rotation.z=moving?Math.sin(gait)*.018:0;
|
||||
const blinking=Math.sin(time*1.3+index)>.993;
|
||||
actor.eyes.forEach(eye=>eye.scale.y=blinking?.12:1);
|
||||
actor.head.rotation.y = shopping ? -.65 : watchRabbit ? .55 : Math.sin(time * .7) * .06;
|
||||
actor.torso.rotation.x = delivery && index === 1 ? .22 : 0;
|
||||
actor.legs.forEach((leg, i) => {
|
||||
const phase = gait + i * Math.PI;
|
||||
leg.hip.rotation.x = finished ? -1.3 : moving ? Math.sin(phase) * .34 : 0;
|
||||
leg.foot.rotation.x=moving?Math.sin(phase+.5)*.12:0;
|
||||
leg.knee.rotation.x = finished ? 1.5 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05;
|
||||
});
|
||||
});
|
||||
group.updateMatrixWorld(true);
|
||||
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
|
||||
const shoulder = (actor, side) => toFamily(actor, new THREE.Vector3(side * .29 * actor.scale, 1.43 * actor.scale, 0));
|
||||
// With characters facing +X, father's left and child's right hands meet.
|
||||
const contact = new THREE.Vector3(0, 1.06, 0);
|
||||
actors.forEach((actor, index) => actor.arms.forEach((arm, i) => {
|
||||
const side = i === 0 ? -1 : 1, inside = index === 0 ? side === -1 : side === 1;
|
||||
const a = shoulder(actor, side);
|
||||
let target;
|
||||
if (inside && !shopping && !finished) target = contact.clone();
|
||||
else if (index === 1 && shopping && side === -1) target = new THREE.Vector3(.5, delivery ? .65 : 1.1, -.15);
|
||||
else target = toFamily(actor, new THREE.Vector3(side * .34 * actor.scale, .88 * actor.scale, moving ? Math.sin(gait + (i ? Math.PI : 0)) * .2 : 0));
|
||||
const elbow = a.clone().lerp(target, .5).add(new THREE.Vector3(.07, 0, inside ? 0 : .07));
|
||||
updateSegment(arm.upper, a, elbow);
|
||||
updateSegment(arm.lower, elbow, target);
|
||||
arm.hand.position.copy(target);
|
||||
}));
|
||||
}
|
||||
|
||||
update({time: 0, gait: 0, moving: false, facing: Math.PI / 2});
|
||||
return {group, update};
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
// Dialogue deliberately never locks walking. Only transactional UI and delivery do.
|
||||
export function movementDirection(pressed, held) {
|
||||
return Number(pressed.has('arrowright') || pressed.has('d') || held.has(1))
|
||||
- Number(pressed.has('arrowleft') || pressed.has('a') || held.has(-1));
|
||||
}
|
||||
export function canWalk({shopOpen=false, notebookOpen=false, endingOpen=false, busy=false, finished=false}) {
|
||||
return !shopOpen && !notebookOpen && !endingOpen && !busy && !finished;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export const INITIAL_BALANCE = 500;
|
||||
export const formatHetoman = value => `${value.toLocaleString('fa-IR')} هتومان`;
|
||||
export const hetomanToRial = value => value * 10000;
|
||||
|
||||
const assortment = [
|
||||
{id: 'juice', code:'11', name: 'آبمیوه', icon: '🧃', cost: 35000, type: 'drink'},
|
||||
{id: 'water', code:'12', name: 'آب معدنی', icon: '💧', cost: 15000, type: 'drink'},
|
||||
{id: 'biscuit', code:'13', name: 'بیسکویت', icon: '🍪', cost: 25000, type: 'snack'},
|
||||
{id:'milk',code:'14',name:'شیر کاکائو',icon:'🥛',cost:40000,type:'drink'},
|
||||
{id:'tea',code:'15',name:'چای سرد',icon:'🧋',cost:45000,type:'drink'},
|
||||
{id:'lemonade',code:'16',name:'لیموناد',icon:'🍋',cost:40000,type:'drink'},
|
||||
{id:'chocolate',code:'17',name:'شکلات',icon:'🍫',cost:30000,type:'snack'},
|
||||
{id:'nuts',code:'18',name:'آجیل',icon:'🥜',cost:65000,type:'snack'},
|
||||
{id:'cake',code:'19',name:'کیک',icon:'🧁',cost:35000,type:'snack'}
|
||||
];
|
||||
|
||||
// All 60 physical channels exist; only 21–60 are available in this scene.
|
||||
export const channels=Array.from({length:60},(_,index)=>{
|
||||
const channel=index+1,base=assortment[(channel-22+63)%assortment.length];
|
||||
return {...base,baseId:base.id,id:`channel-${channel}`,code:String(channel),channel,cost:base.cost/1000,available:channel>=21};
|
||||
});
|
||||
export const products=channels.filter(channel=>channel.available);
|
||||
|
||||
export function createState() {
|
||||
return {balance: INITIAL_BALANCE, basket: [], busy: false, finished: false, recycled: false};
|
||||
}
|
||||
|
||||
export function readyForPicnic(state) {
|
||||
return state.basket.some(p => p.type === 'drink') && state.basket.some(p => p.type === 'snack');
|
||||
}
|
||||
|
||||
export function purchase(state, id) {
|
||||
const product = products.find(p => p.id === id);
|
||||
if (!product || state.busy || state.finished || state.balance < product.cost) return null;
|
||||
// Keep enough money for the missing picnic category, so the quest stays completable.
|
||||
const next = [...state.basket, product];
|
||||
const reserve = ['drink', 'snack'].reduce((total, type) => total + (next.some(p => p.type === type) ? 0 : Math.min(...products.filter(p => p.type === type).map(p => p.cost))), 0);
|
||||
if (state.balance - product.cost < reserve) return null;
|
||||
state.balance -= product.cost;
|
||||
state.basket.push(product);
|
||||
state.busy = true;
|
||||
return product;
|
||||
}
|
||||
|
||||
export function productByCode(value) {
|
||||
const normalized=String(value).trim().replace(/[۰-۹]/g,c=>String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c))).replace(/[٠-٩]/g,c=>String('٠١٢٣٤٥٦٧٨٩'.indexOf(c)));
|
||||
return products.find(p=>p.code===normalized) ?? null;
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
import * as THREE from 'three';
|
||||
import './style.css';
|
||||
import {createPayment,beginPayment,swipePayment,cancelPayment} from './payment.js';
|
||||
import {movementDirection,canWalk} from './controls.js';
|
||||
import {chapters,createStory,completeChapter,nextChapter,storyReady,routeOffset,discoveries} from './story.js';
|
||||
import {createFamily} from './characters.js';
|
||||
import {products,createState,readyForPicnic,purchase,productByCode,channels,formatHetoman} from './game-state.js';
|
||||
|
||||
// One shared path coordinate drives both characters, their gait and the camera.
|
||||
const canvas = document.querySelector('#world');
|
||||
let renderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({canvas, antialias: true});
|
||||
} catch {
|
||||
document.querySelector('#loading').textContent = 'مرورگر شما گرافیک سهبعدی را اجرا نمیکند. شتابدهی گرافیکی را فعال کنید.';
|
||||
throw new Error('WebGL unavailable');
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor('#a4b492');
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2('#a4b492', .018);
|
||||
const camera = new THREE.PerspectiveCamera(42, 1, .1, 140);
|
||||
scene.add(new THREE.HemisphereLight('#fff3cc', '#375946', 2.8));
|
||||
const sun = new THREE.DirectionalLight('#ffe5a3', 3.3);
|
||||
sun.position.set(-5, 12, -8);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(2048, 2048);
|
||||
Object.assign(sun.shadow.camera, {left: -24, right: 24, top: 24, bottom: -24, far: 60});
|
||||
scene.add(sun);
|
||||
let story=createStory(),chapterId=null,chapterLine=0,chapterTimer=0;
|
||||
const material = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .85, ...extra});
|
||||
const green = material('#6d975b'), bark = material('#755a3e'), dark = material('#172c2a');
|
||||
|
||||
function mesh(geometry, mat, parent = scene, position = [0, 0, 0]) {
|
||||
const m = new THREE.Mesh(geometry, mat);
|
||||
m.position.set(...position);
|
||||
m.castShadow = true;
|
||||
m.receiveShadow = true;
|
||||
parent.add(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function box(w, h, d, mat, parent, pos) {
|
||||
return mesh(new THREE.BoxGeometry(w, h, d), mat, parent, pos)
|
||||
}
|
||||
|
||||
function ball(r, mat, parent, pos) {
|
||||
return mesh(new THREE.SphereGeometry(r, 12, 10), mat, parent, pos)
|
||||
}
|
||||
|
||||
const ground = mesh(new THREE.PlaneGeometry(160, 120), green);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
|
||||
// A broad, gently curving ribbon defines the walking route.
|
||||
function pathZ(x) {
|
||||
return Math.sin(x * .17) * 1.5 + routeOffset(story,x)
|
||||
}
|
||||
|
||||
const points = [];
|
||||
for (let x = -42; x <= 50; x += .5) points.push(new THREE.Vector3(x, .025, pathZ(x)));
|
||||
const curve = new THREE.CatmullRomCurve3(points);
|
||||
const path = mesh(new THREE.TubeGeometry(curve, 180, 1.35, 8, false), material('#cbb889'));
|
||||
path.scale.y = .025;
|
||||
let seed = 42;
|
||||
|
||||
function random() {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 4294967296
|
||||
}
|
||||
|
||||
const trees=[];
|
||||
function tree(x, z, s) {
|
||||
const g = new THREE.Group();
|
||||
g.position.set(x, 0, z);
|
||||
g.scale.setScalar(s);
|
||||
trees.push(g);
|
||||
scene.add(g);
|
||||
mesh(new THREE.CylinderGeometry(.17, .3, 2.6, 7), bark, g, [0, 1.3, 0]);
|
||||
const foliage = material(['#315e42', '#47764b', '#597f4c', '#749759'][Math.floor(random() * 4)]);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
mesh(new THREE.ConeGeometry(1.55 - i * .25, 2.6, 7), foliage, g, [0, 2.7 + i * .9, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 130; i++) {
|
||||
const x = random() * 100 - 45, z = random() * 65 - 33;
|
||||
if (Math.abs(z - pathZ(x)) > 3.6 && !(Math.abs(x - 9) < 4 && z > -5 && z < 2) && !(x>17 && x<28 && Math.abs(z)<7)) tree(x, z, .7 + random() * 1.2)
|
||||
}
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const x = random() * 65 - 25, z = random() * 32 - 16;
|
||||
if (Math.abs(z - pathZ(x)) < 1.7) continue;
|
||||
const rock = mesh(new THREE.DodecahedronGeometry(.13 + random() * .3), material('#8c9c78'), scene, [x, .12, z]);
|
||||
rock.scale.y = .6;
|
||||
}
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = random() * 55 - 20, z = random() * 25 - 12;
|
||||
if (Math.abs(z - pathZ(x)) < 1.5) continue;
|
||||
ball(.065, material(i % 3 ? '#eadba0' : '#e4ae89'), scene, [x, .22, z]);
|
||||
}
|
||||
|
||||
const characters=createFamily();
|
||||
const family=characters.group;
|
||||
scene.add(family);
|
||||
|
||||
// Procedural vending machine, with a canvas sign and visible product rows.
|
||||
const stationX = 9, stationZ = pathZ(9) - 2.65;
|
||||
const machine = new THREE.Group();
|
||||
machine.position.set(stationX, 0, stationZ);
|
||||
machine.rotation.y = .13;
|
||||
scene.add(machine);
|
||||
box(1.75, 2.9, 1.1, dark, machine, [0, 1.48, 0]);
|
||||
box(1.25, 1.85, .08, material('#a6d5c3', {metalness: .4, roughness: .22}), machine, [-.13, 1.55, .59]);
|
||||
box(1.13, 1.72, .1, material('#223f3b'), machine, [-.13, 1.55, .65]);
|
||||
const bottleMats = ['#e5ae47', '#badd81', '#c8704e'].map(c => material(c));
|
||||
const steel=material('#aeb8b6',{metalness:.8,roughness:.3});
|
||||
for(let row=0;row<6;row++){
|
||||
const shelfY=.86+row*.275;box(1.17,.025,.28,steel,machine,[-.13,shelfY,.73]);
|
||||
for(let col=0;col<10;col++){
|
||||
const channel=(5-row)*10+col+1,px=-.65+col*.115;
|
||||
if(channel%3){mesh(new THREE.CylinderGeometry(.033,.04,.155,10),bottleMats[channel%3],machine,[px,shelfY+.105,.73]);box(.035,.035,.035,dark,machine,[px,shelfY+.195,.73]);}
|
||||
else box(.068,.14,.07,bottleMats[channel%3],machine,[px,shelfY+.09,.73]);
|
||||
// Each slot has its own dispensing spiral.
|
||||
const spiral=mesh(new THREE.TorusGeometry(.035,.006,4,10),steel,machine,[px,shelfY+.045,.79]);
|
||||
}
|
||||
}
|
||||
const slots=document.createElement('canvas');slots.width=1000;slots.height=900;
|
||||
const slotContext=slots.getContext('2d');slotContext.textAlign='center';slotContext.font='bold 25px sans-serif';
|
||||
for(let row=0;row<6;row++)for(let col=0;col<10;col++){
|
||||
const n=row*10+col+1;slotContext.fillStyle=n>=21?'#d1f0b3':'#9ca6a0';slotContext.fillRect(col*100+8,row*150+120,84,30);slotContext.fillStyle='#172c2a';slotContext.fillText(String(n),col*100+50,row*150+144);
|
||||
}
|
||||
const slotLabels=mesh(new THREE.PlaneGeometry(1.16,1.65),new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(slots),transparent:true}),machine,[-.13,1.56,.82]);
|
||||
for(const px of [-.77,.48])box(.025,1.85,.08,steel,machine,[px,1.55,.82]);
|
||||
const glass=mesh(new THREE.PlaneGeometry(1.24,1.86),material('#d5eee9',{transparent:true,opacity:.1,metalness:.3,roughness:.08,depthWrite:false}),machine,[-.13,1.55,.84]);
|
||||
box(.04,.5,.06,steel,machine,[.52,1.4,.88]);
|
||||
for(let r=0;r<4;r++)for(let c=0;c<3;c++)box(.045,.045,.035,steel,machine,[.60+c*.065,1.43-r*.07,.64]);
|
||||
for(let i=0;i<6;i++)box(.24,.014,.025,steel,machine,[.67,.55+i*.045,.58]);
|
||||
for(const px of [-.65,.65])box(.15,.09,.6,dark,machine,[px,.015,0]);
|
||||
box(.24, .38, .08, material('#81d9ba', {emissive: '#50a486', emissiveIntensity: .5}), machine, [.67, 1.7, .62]);
|
||||
for (let i = 0; i < 3; i++) box(.15, .05, .08, material('#e4d7ad'), machine, [.67, 1.3 - i * .1, .62]);
|
||||
box(.85, .3, .1, material('#050f10'), machine, [-.13, .36, .62]);
|
||||
const label = document.createElement('canvas');
|
||||
label.width = 512;
|
||||
label.height = 128;
|
||||
const ctx = label.getContext('2d');
|
||||
ctx.fillStyle = '#193c30';
|
||||
ctx.fillRect(0, 0, 512, 128);
|
||||
ctx.fillStyle = '#d9efa7';
|
||||
ctx.font = 'bold 57px Tahoma';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('روبات مارکت', 256, 83);
|
||||
const sign = material('#ffffff', {map: new THREE.CanvasTexture(label), emissive: '#9cce91', emissiveIntensity: .18});
|
||||
box(1.5, .38, .05, sign, machine, [0, 2.68, .58]);
|
||||
// A separate card, animated through the reader only after an explicit swipe.
|
||||
const bankCard=box(.24,.15,.014,material('#ddd4b4',{metalness:.2}),machine,[.9,1.8,.8]);
|
||||
box(.2,.03,.016,dark,bankCard,[0,.035,.008]);bankCard.visible=false;
|
||||
const pad = mesh(new THREE.CylinderGeometry(2.3, 2.4, .12, 32), material('#9caa80'), scene, [9, .05, stationZ]);
|
||||
const delivery = new THREE.Group(); scene.add(delivery);
|
||||
const deliveryBody=mesh(new THREE.CylinderGeometry(.1,.1,.28,10),bottleMats[0],delivery);
|
||||
box(.09,.05,.09,dark,delivery,[0,.16,0]);
|
||||
delivery.visible = false;
|
||||
|
||||
// Picnic clearing, checked blanket, basket and a recycling bin.
|
||||
const picnic=new THREE.Group();picnic.position.set(23,1.8,pathZ(22));scene.add(picnic);
|
||||
for(let i=0;i<6;i++)for(let j=0;j<5;j++)box(.5,.035,.5,material((i+j)%2?'#f3dca9':'#b96f51'),picnic,[(i-2.5)*.5,.08,(j-2)*.5]);
|
||||
const picnicFood=new THREE.Group();picnic.add(picnicFood);
|
||||
box(.5,.3,.35,material('#ac8252'),picnic,[-1,.25,-.8]);
|
||||
mesh(new THREE.TorusGeometry(.22,.035,6,16,Math.PI),bark,picnic,[-1,.5,-.8]);
|
||||
const bin=box(.55,.8,.55,material('#39735b'),scene,[24,2.2,pathZ(22)-2.3]);
|
||||
box(.65,.08,.65,dark,scene,[24,2.64,pathZ(22)-2.3]);
|
||||
const flap=new THREE.Group();flap.position.set(-.13,.53,.69);machine.add(flap);
|
||||
box(.87,.28,.035,material('#527268',{metalness:.45}),flap,[0,-.14,0]);
|
||||
const glow=new THREE.PointLight('#a8ffc5',2,7);glow.position.set(9,2,stationZ+1);scene.add(glow);
|
||||
const butterfly=new THREE.Group();scene.add(butterfly);
|
||||
const wings=[-1,1].map(side=>{const wing=mesh(new THREE.SphereGeometry(.16,8,6),material('#efb75b'),butterfly,[side*.14,0,0]);wing.scale.set(1,.12,1.4);return wing;});
|
||||
// A raised footbridge crosses a shallow stream before the vending stop.
|
||||
const bridgeX=0;
|
||||
const water=mesh(new THREE.PlaneGeometry(3.4,40),material('#68b4b6',{metalness:.3,roughness:.25}),scene,[bridgeX,.035,0]);water.rotation.x=-Math.PI/2;
|
||||
const bridge=new THREE.Group();scene.add(bridge);
|
||||
const wood=material('#a57b50');
|
||||
for(let i=0;i<13;i++){const bx=-2+i/3;box(.29,.14,2.8,wood,bridge,[bx,.22,pathZ(bx)]);}
|
||||
for(const side of [-1,1]){
|
||||
for(const bx of [-2,0,2])box(.12,1,.12,bark,bridge,[bx,.65,pathZ(bx)+side*1.3]);
|
||||
const rail=box(4.3,.1,.1,wood,bridge,[0,1.08,side*1.3]);rail.rotation.y=-.25;
|
||||
}
|
||||
function pathHeight(at){return Math.max(0,Math.min(1,(2.6-Math.abs(at))/.6))*.3+THREE.MathUtils.smoothstep(at,17,22)*1.8;}
|
||||
const rabbit=new THREE.Group();scene.add(rabbit);
|
||||
const fur=material('#eee2ca'),pink=material('#d5a193');
|
||||
const rabbitBody=ball(.28,fur,rabbit,[0,.3,0]);rabbitBody.scale.set(1.3,1,1);
|
||||
ball(.22,fur,rabbit,[.24,.55,0]);ball(.12,fur,rabbit,[-.35,.4,0]);
|
||||
for(const side of [-1,1]){const ear=ball(.1,fur,rabbit,[.23,.86,side*.1]);ear.scale.set(.75,2.5,.75);const inset=ball(.065,pink,rabbit,[.29,.88,side*.1]);inset.scale.set(.4,2.6,.7);ball(.025,dark,rabbit,[.43,.58,side*.13]);ball(.09,fur,rabbit,[.1,.1,side*.2]);}
|
||||
const lostPaper=box(.6,.025,.43,material('#fff3d5'),scene,[14,.35,Math.sin(14*.17)*1.5+1.5]);lostPaper.rotation.z=.15;
|
||||
const bush=ball(.55,material('#688a4b'),scene,[14,.3,Math.sin(14*.17)*1.5+2]);
|
||||
const oldTree=new THREE.Group();oldTree.position.set(19,0,-5);scene.add(oldTree);mesh(new THREE.CylinderGeometry(.5,.8,4,9),bark,oldTree,[0,2,0]);ball(2,material('#54734a'),oldTree,[0,4.8,0]);
|
||||
const hill=mesh(new THREE.SphereGeometry(5,24,16),green,scene,[23,-1.3,0]);hill.scale.set(1,.65,1.3);
|
||||
let branchPath=null;
|
||||
function rebuildRoute(){
|
||||
if(branchPath){scene.remove(branchPath);branchPath.geometry.dispose();branchPath.material.dispose();}
|
||||
const pts=[];for(let at=11;at<=24;at+=.15)pts.push(new THREE.Vector3(at,pathHeight(at)+.045,pathZ(at)));
|
||||
branchPath=mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts),80,.8,8,false),material('#cbb889'));
|
||||
// Flatten only the cross-section, retaining the hill's elevation.
|
||||
const positions=branchPath.geometry.attributes.position;
|
||||
for(let i=0;i<positions.count;i++){const px=positions.getX(i);positions.setY(i,pathHeight(px)+.045);}
|
||||
positions.needsUpdate=true;branchPath.geometry.computeVertexNormals();
|
||||
}
|
||||
rebuildRoute();
|
||||
const fishes=[];for(let i=0;i<4;i++){const fish=ball(.09,material('#e8b365'),scene,[.4,.11,-.6+i*.4]);fish.scale.set(2,.5,1);fishes.push(fish);}
|
||||
let encounterTime=0,rabbitSeen=false,encounterActive=false,facing=Math.PI/2;
|
||||
const $=s=>document.querySelector(s),shop=$('#shop'),ending=$('#ending');
|
||||
const storyDialog=$('#story-dialog'),notebook=$('#notebook');
|
||||
function renderChapter(){
|
||||
const chapter=chapters[chapterId];$('#chapter-title').textContent=chapter.title;$('#chapter-line').textContent=chapter.lines[chapterLine];$('#chapter-count').textContent=`${chapterLine+1} / ${chapter.lines.length}`;$('#chapter-actions').replaceChildren();
|
||||
const last=chapterLine===chapter.lines.length-1;
|
||||
if(last&&chapter.action){const b=document.createElement('button');b.type='button';b.textContent=`${chapter.action.label} · F`;b.onclick=performStoryAction;$('#chapter-actions').append(b);}
|
||||
chapterTimer=Math.max(2.5,chapter.lines[chapterLine].length*.055);
|
||||
}
|
||||
function performStoryAction(){
|
||||
if(!chapterId||shop.open||notebook.open||ending.open||state.busy)return;
|
||||
const chapter=chapters[chapterId];
|
||||
if(chapter.action&&chapterLine===chapter.lines.length-1)finishChapter(chapter.action.value);
|
||||
}
|
||||
function showChapter(id){chapterId=id;chapterLine=0;storyDialog.hidden=false;renderChapter();}
|
||||
function finishChapter(choice){
|
||||
const id=chapterId;if(!completeChapter(story,id,choice))return;
|
||||
if(id==='rabbit'){rabbitSeen=true;encounterTime=0;}
|
||||
if(id==='fork'||id==='wind')rebuildRoute();
|
||||
storyDialog.hidden=true;chapterId=null;syncHUD();
|
||||
if(id==='summit'){drawMemory($('#memory'));$('#ending-discoveries').textContent=discoveries(story).join(' · ');ending.showModal();}
|
||||
}
|
||||
|
||||
function drawMemory(canvas){
|
||||
const c=canvas.getContext('2d');c.clearRect(0,0,600,360);c.fillStyle=story.sky==='gold'?'#f5d18b':story.sky==='rose'?'#eeb9ba':'#b9dfeb';c.fillRect(0,0,600,360);c.fillStyle='#fff0af';c.beginPath();c.arc(500,65,30,0,Math.PI*2);c.fill();c.fillStyle='#84a36b';c.beginPath();c.moveTo(0,240);c.quadraticCurveTo(160,90,320,235);c.quadraticCurveTo(460,120,600,210);c.lineTo(600,360);c.lineTo(0,360);c.fill();
|
||||
for(let i=0;i<5;i++){c.fillStyle='#6a553e';c.fillRect(30+i*115,180,8,75);c.fillStyle='#456b47';c.beginPath();c.moveTo(5+i*115,205);c.lineTo(34+i*115,125);c.lineTo(63+i*115,205);c.fill();}
|
||||
c.strokeStyle='#8a674d';c.lineWidth=8;c.beginPath();c.moveTo(75,278);c.lineTo(210,278);c.moveTo(85,263);c.lineTo(200,263);c.stroke();
|
||||
function person(px,py,size,color){c.fillStyle='#edbb94';c.beginPath();c.arc(px,py,size*.18,0,Math.PI*2);c.fill();c.strokeStyle=color;c.lineWidth=size*.16;c.beginPath();c.moveTo(px,py+size*.22);c.lineTo(px,py+size*.65);c.moveTo(px,py+size*.3);c.lineTo(px+size*.3,py+size*.48);c.moveTo(px,py+size*.65);c.lineTo(px-size*.15,py+size);c.moveTo(px,py+size*.65);c.lineTo(px+size*.15,py+size);c.stroke();}
|
||||
person(300,200,90,'#b28b48');person(347,235,58,'#c56f4d');c.strokeStyle='#d4a77d';c.lineWidth=5;c.beginPath();c.moveTo(327,243);c.lineTo(347,252);c.stroke();c.font='20px Tahoma';c.textAlign='center';c.fillStyle='#294b3c';c.fillText(story.sky?'من و بابا، تپهٔ آرزوها':'من و بابا… ادامه دارد',300,338);
|
||||
}
|
||||
$('#notebook-open').onclick=()=>{clearInput();$('#discoveries').textContent=discoveries(story).join(' · ')||'هنوز اول راهیم؛ کشفهای امروز اینجا ثبت میشن.';drawMemory($('#drawing'));notebook.showModal();};$('#notebook-close').onclick=()=>notebook.close();notebook.addEventListener('close',clearInput);
|
||||
let payment=createPayment(),paymentTime=0;
|
||||
let state=createState(),x=-8,moving=0,gait=0,deliveryTime=0,activeProduct=null,elapsed=0;
|
||||
const pressed=new Set(),held=new Set();
|
||||
const fa=n=>n.toLocaleString('fa-IR');
|
||||
let audio=null,soundEnabled=false,nextBird=0,zoomDistance=1;
|
||||
function tone(frequency,duration=.12,volume=.04){if(!soundEnabled||!audio)return;const oscillator=audio.createOscillator(),gain=audio.createGain();oscillator.frequency.setValueAtTime(frequency,audio.currentTime);oscillator.frequency.exponentialRampToValueAtTime(frequency*.7,audio.currentTime+duration);gain.gain.setValueAtTime(volume,audio.currentTime);gain.gain.exponentialRampToValueAtTime(.001,audio.currentTime+duration);oscillator.connect(gain).connect(audio.destination);oscillator.start();oscillator.stop(audio.currentTime+duration);}
|
||||
$('#sound').onclick=async()=>{try{audio??=new AudioContext();await audio.resume();soundEnabled=!soundEnabled;$('#sound').textContent=soundEnabled?'صدا: روشن ♫':'صدا: خاموش ♫';$('#sound').setAttribute('aria-pressed',String(soundEnabled));tone(700);}catch{$('#sound').textContent='صدا در دسترس نیست';}};
|
||||
function syncHUD(){
|
||||
$('#balance').textContent=fa(state.balance);
|
||||
$('#basket').textContent=state.basket.length?'🧺 '+state.basket.map(p=>p.icon).join(' '):'🧺 سبد پیکنیک خالی است';
|
||||
$('#mission').textContent=!story.done.includes('courage')?'مسیر تپه را پیدا کنید':!story.done.includes('paper')&&story.done.includes('wind')?'نقاشی را کنار بوته پیدا کنید':story.done.includes('paper')&&!story.route?'برای نقاشی یک جای قشنگ پیدا کنید':readyForPicnic(state)?'سبد آماده است؛ به زیرانداز برسید':state.basket.some(p=>p.type==='drink')?'یک خوراکی هم برای پیکنیک بردارید':'یک نوشیدنی و یک خوراکی تهیه کنید';
|
||||
}
|
||||
function selectChannel(code){
|
||||
$('#product-code').value=code;$('#product-code').removeAttribute('aria-invalid');$('#shop-status').textContent='';
|
||||
document.querySelectorAll('[data-channel]').forEach(node=>node.classList.toggle('selected',node.dataset.channel===code));
|
||||
const row=$(`#products [data-channel="${code}"]`);row?.scrollIntoView({block:'nearest'});
|
||||
}
|
||||
function renderProducts(){
|
||||
syncHUD();$('#products').replaceChildren();$('#channels').replaceChildren();
|
||||
products.forEach(p=>{const button=document.createElement('button');button.type='button';button.textContent=fa(p.channel);button.dataset.channel=p.code;button.disabled=!p.available;button.setAttribute('aria-label',`کانال ${fa(p.channel)}${p.available?'':'؛ غیرفعال'}`);button.onclick=()=>selectChannel(p.code);$('#channels').append(button);});
|
||||
products.forEach(p=>{const card=document.createElement('button');card.type='button';card.className='product-code-card';card.dataset.channel=p.code;card.innerHTML=`<span class="icon">${p.icon}</span><b>کد ${fa(p.channel)} · ${p.name}<small>محصول فرضی کانال ${fa(p.channel)}</small></b><span>${formatHetoman(p.cost)}</span>`;card.onclick=()=>selectChannel(p.code);$('#products').append(card);});
|
||||
}
|
||||
function deliver(p){
|
||||
activeProduct=p;deliveryTime=3.4;delivery.visible=true;deliveryBody.material=bottleMats[p.type==='snack'?2:p.baseId==='water'?1:0];
|
||||
renderProducts();shop.close();pressed.clear();held.clear();tone(480,.3);
|
||||
$('#speech').textContent='پدر: ببین، دستگاه داره انتخابت رو آماده میکنه!';
|
||||
}
|
||||
function openShop(){if(Math.abs(x-stationX)<2.5&&!shop.open&&!notebook.open&&!state.busy&&!state.finished){pressed.clear();held.clear();$('#shop-status').textContent='بچه: خودم میتونم انتخاب کنم؟ پدر: حتماً! برای نوشیدنی و خوراکی پول کنار بگذار.';renderProducts();$('#product-code').value='';$('#product-code').removeAttribute('aria-invalid');shop.showModal();$('#product-code').focus();tone(750);}}
|
||||
$('#code-form').onsubmit=e=>{
|
||||
e.preventDefault();
|
||||
if(state.busy||state.finished||!shop.open)return;
|
||||
const field=$('#product-code'),product=productByCode(field.value);
|
||||
let error='';
|
||||
if(!field.value.trim())error='کد محصول را وارد کن.';
|
||||
else if(!product)error='کانال قابل خرید نیست؛ عددی از ۲۱ تا ۶۰ وارد کن.';
|
||||
else if(state.balance<product.cost)error='برای این محصول موجودی کافی نداری.';
|
||||
else if(!purchase({...state,basket:[...state.basket]},product.id))error='برای تکمیل پیکنیک، هزینهٔ نوشیدنی و خوراکی را کنار بگذار؛ محصول دیگری انتخاب کن.';
|
||||
if(error){$('#shop-status').textContent=error;field.setAttribute('aria-invalid','true');field.focus();tone(200,.2);return;}
|
||||
field.removeAttribute('aria-invalid');
|
||||
if(beginPayment(state,payment,product)){
|
||||
paymentTime=0;shop.close();clearInput();$('#payment-panel').hidden=false;$('#cancel-payment').hidden=false;
|
||||
$('#payment-message').textContent=`کانال ${fa(product.channel)} · ${product.name} · ${formatHetoman(product.cost)}`;
|
||||
$('#swipe-card').disabled=true;$('#payment-result').textContent='در حال نزدیک شدن به کارتخوان…';
|
||||
}
|
||||
};
|
||||
function swipeCard(){
|
||||
if(!swipePayment(state,payment))return;
|
||||
paymentTime=0;bankCard.visible=true;$('#swipe-card').disabled=true;$('#cancel-payment').hidden=true;
|
||||
$('#payment-result').textContent='خرید موفق ✓ محصول در حال تحویل است';tone(1100,.3);syncHUD();
|
||||
}
|
||||
$('#swipe-card').onclick=swipeCard;
|
||||
$('#cancel-payment').onclick=()=>{if(cancelPayment(state,payment)){$('#payment-panel').hidden=true;bankCard.visible=false;clearInput();}};
|
||||
$('#product-code').addEventListener('input',()=>{document.querySelectorAll('[data-channel]').forEach(node=>node.classList.remove('selected'));$('#product-code').removeAttribute('aria-invalid');$('#shop-status').textContent='';});
|
||||
$('#interact').onclick=openShop;$('#close').onclick=()=>shop.close();
|
||||
window.addEventListener('keydown',e=>{if(e.key.toLowerCase()==='f'){if(!e.repeat)performStoryAction();return;}if(e.key.toLowerCase()==='c'&&payment.phase==='ready'){e.preventDefault();swipeCard();return;}if(notebook.open||ending.open)return;if(['ArrowRight','ArrowLeft','ArrowUp','ArrowDown',' '].includes(e.key)&&!shop.open&&!ending.open)e.preventDefault();pressed.add(e.key.toLowerCase());if(e.key.toLowerCase()==='e')openShop();});
|
||||
window.addEventListener('keyup',e=>pressed.delete(e.key.toLowerCase()));
|
||||
function clearInput(){pressed.clear();held.clear();}window.addEventListener('blur',clearInput);document.addEventListener('visibilitychange',clearInput);shop.addEventListener('close',clearInput);
|
||||
for(const [id,dir] of [['back',-1],['forward',1],['zoom-in','in'],['zoom-out','out']]){const b=$('#'+id);b.onpointerdown=e=>{if((state.busy||state.finished)&&typeof dir==='number')return;b.setPointerCapture(e.pointerId);held.add(dir);};b.onpointerup=b.onpointercancel=b.onlostpointercapture=()=>held.delete(dir);}
|
||||
function reset(){payment=createPayment();paymentTime=0;$('#payment-panel').hidden=true;bankCard.visible=false;story=createStory();chapterId=null;storyDialog.hidden=true;chapterTimer=0;if(notebook.open)notebook.close();rebuildRoute();state=createState();x=-8;deliveryTime=0;delivery.visible=false;activeProduct=null;flap.rotation.x=0;encounterTime=0;rabbitSeen=false;encounterActive=false;facing=Math.PI/2;zoomDistance=1;picnicFood.clear();clearInput();if(shop.open)shop.close();if(ending.open)ending.close();$('#badge').textContent='';$('#recycle').disabled=false;$('#recycle').textContent='بستهها را در سطل بینداز ♻';$('#speech').textContent='بچه: بابا، برای پیکنیک چی بخریم؟';syncHUD();}
|
||||
$('#restart').onclick=reset;$('#again').onclick=reset;
|
||||
$('#recycle').onclick=()=>{state.recycled=true;$('#badge').textContent='🏅 نشان دوست جنگل را گرفتی!';$('#recycle').disabled=true;$('#recycle').textContent='جنگل تمیز ماند ✓';$('#speech').textContent='بچه: چیزی جا نذاشتیم؟ پدر: فقط رد پامون.';tone(880,.4);};
|
||||
function finish(){state.finished=true;clearInput();state.basket.slice(0,10).forEach((p,i)=>{const item=mesh(p.type==='drink'?new THREE.CylinderGeometry(.09,.09,.3,8):new THREE.BoxGeometry(.25,.09,.2),bottleMats[p.type==='drink'?0:2],picnicFood,[-.5+(i%5)*.28,.25,Math.floor(i/5)*.3]);});$('#ending-text').textContent=`با ${fa(state.basket.length)} خوراکی و ${formatHetoman(state.balance)} باقیمانده، وقت یک استراحت دونفره است.`;$('#ending-basket').textContent=state.basket.map(p=>p.icon).join(' ');$('#speech').textContent='بچه: بهترین قسمت گردش، با تو بودن بود بابا!';$('#mission').textContent='پیکنیک آماده است؛ یک خاطرهٔ خوب ساختید';tone(660,.5);showChapter('summit');}
|
||||
// The ending opens on request so the picnic remains visible before the overlay.
|
||||
const celebrate=document.createElement('button');celebrate.id='celebrate';celebrate.textContent='پیکنیک و نشان دوست جنگل 🌿';celebrate.hidden=true;document.body.append(celebrate);celebrate.onclick=()=>{if(!story.done.includes('summit'))showChapter('summit');else ending.showModal();};
|
||||
ending.addEventListener('cancel',clearInput);
|
||||
function resize(){renderer.setSize(innerWidth,innerHeight);camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();}window.addEventListener('resize',resize);resize();
|
||||
const camTarget=new THREE.Vector3(),look=new THREE.Vector3(x+1,.8,pathZ(x)),lookTarget=new THREE.Vector3();camera.position.set(x+7,10,14);
|
||||
let last=performance.now();
|
||||
function frame(now){
|
||||
requestAnimationFrame(frame);const dt=Math.min((now-last)/1000,.1);last=now;elapsed+=dt;
|
||||
const direction=movementDirection(pressed,held);
|
||||
const next=nextChapter(story,x);if(next&&!chapterId&&!shop.open&&!ending.open&&!notebook.open&&!state.busy&&!state.finished)showChapter(next);
|
||||
if(chapterId&&!shop.open&&!notebook.open&&!ending.open&&!document.hidden){
|
||||
const chapter=chapters[chapterId],lastLine=chapterLine===chapter.lines.length-1;
|
||||
if(!(lastLine&&chapter.action)){
|
||||
chapterTimer-=dt;
|
||||
if(chapterTimer<=0){if(!lastLine){chapterLine++;renderChapter();}else finishChapter();}
|
||||
}
|
||||
}
|
||||
encounterActive=chapterId==='rabbit';
|
||||
moving=canWalk({shopOpen:shop.open,notebookOpen:notebook.open,endingOpen:ending.open,busy:state.busy,finished:state.finished})?direction:0;
|
||||
x=THREE.MathUtils.clamp(x+moving*2.2*dt,-9,22);
|
||||
family.position.set(x,pathHeight(x),pathZ(x));family.rotation.y=-Math.atan(.255*Math.cos(x*.17));
|
||||
const shopping=shop.open||state.busy;
|
||||
if(shopping){family.position.x=THREE.MathUtils.lerp(x,stationX,1-Math.exp(-dt*8));x=family.position.x;}
|
||||
const desiredFacing=shopping?Math.PI:moving<0?-Math.PI/2:moving>0?Math.PI/2:facing;
|
||||
facing+=Math.atan2(Math.sin(desiredFacing-facing),Math.cos(desiredFacing-facing))*(1-Math.exp(-dt*7));
|
||||
if(moving)gait+=dt*6.2;
|
||||
characters.update({dt,time:elapsed,gait,moving:!!moving,facing,shopping,delivery:state.busy,finished:state.finished,watchRabbit:encounterActive});
|
||||
if(state.finished)family.position.x=22.7;
|
||||
rabbit.position.set(-2.5+(rabbitSeen?Math.min(4,encounterTime)*.8:0),rabbitSeen?Math.abs(Math.sin(elapsed*8))*.3:0,pathZ(-2.5)+2.5+(rabbitSeen?Math.min(4,encounterTime):0));
|
||||
if(rabbitSeen)encounterTime=Math.min(4,encounterTime+dt);
|
||||
camTarget.set(x+7,state.finished?8:innerWidth<650?12:9,innerWidth<650?19:13);
|
||||
lookTarget.set(x+1,.8+pathHeight(x),pathZ(x));
|
||||
if(shopping){camTarget.set(stationX+4.2,3.5,stationZ+(innerWidth<650?8:6));lookTarget.set(stationX,.95,stationZ+.9);}
|
||||
if(encounterActive){camTarget.set(x+5,5.5,pathZ(x)+9);lookTarget.set(-2.9,.65,pathZ(-2.5)+1);}
|
||||
if(!shop.open&&!notebook.open&&!ending.open){
|
||||
const zoomInput=Number(pressed.has('arrowdown')||held.has('out'))-Number(pressed.has('arrowup')||held.has('in'));
|
||||
zoomDistance=THREE.MathUtils.clamp(zoomDistance+zoomInput*dt*.45,.6,1.6);
|
||||
}
|
||||
camTarget.sub(lookTarget).multiplyScalar(zoomDistance).add(lookTarget);
|
||||
if(['approaching','ready','approved'].includes(payment.phase)){
|
||||
const reader=new THREE.Vector3(.67,1.7,.68);machine.localToWorld(reader);
|
||||
lookTarget.copy(reader);camTarget.copy(reader).add(new THREE.Vector3(.8,.35,1.8));
|
||||
}
|
||||
camera.position.lerp(camTarget,1-Math.exp(-dt*2.8));look.lerp(lookTarget,1-Math.exp(-dt*3));camera.lookAt(look);
|
||||
document.body.classList.toggle('cinematic',shopping||encounterActive);
|
||||
const near=Math.abs(x-stationX)<2.5;
|
||||
// Project the prompt from the reader into screen space; it follows the machine.
|
||||
camera.updateMatrixWorld();machine.updateMatrixWorld(true);
|
||||
const promptPoint=machine.localToWorld(new THREE.Vector3(.55,2.15,.9)).project(camera);
|
||||
const promptVisible=near&&!shop.open&&!notebook.open&&!ending.open&&!state.busy&&!state.finished&&promptPoint.z>-1&&promptPoint.z<1;
|
||||
$('#interact').hidden=!promptVisible;
|
||||
if(promptVisible){$('#interact').style.left=`${THREE.MathUtils.clamp((promptPoint.x*.5+.5)*innerWidth,85,innerWidth-85)}px`;$('#interact').style.top=`${THREE.MathUtils.clamp((-.5*promptPoint.y+.5)*innerHeight,85,innerHeight-100)}px`;}
|
||||
$('#machine-note').hidden=!promptVisible;
|
||||
document.body.classList.toggle('near-machine',promptVisible);
|
||||
celebrate.hidden=!state.finished;
|
||||
$('#progress').style.width=`${Math.min(100,(state.basket.some(p=>p.type==='drink')?30:0)+(state.basket.some(p=>p.type==='snack')?30:0)+(state.finished?40:Math.max(0,(x-11)/11)*35))}%`;
|
||||
if(!state.busy&&!state.finished&&!encounterActive){
|
||||
$('#speech').textContent=near?(readyForPicnic(state)?'بچه: همهچی داریم! بریم سر زیرانداز؟':'بچه: بابا، این فروشنده نداره؟ پدر: خودت انتخاب کن، دستگاه بهت میده!'):x>20&&!readyForPicnic(state)?'پدر: برای پیکنیک یک نوشیدنی و یک خوراکی لازم داریم؛ برگردیم دستگاه.':x>12?'بچه: اون زیرانداز رو ببین! رسیدیم؟':x>0?'پدر: صدای پرندهها رو میشنوی؟ آروم راه بریم.':'بچه: بابا، برای پیکنیک چی بخریم؟';
|
||||
}
|
||||
if(encounterActive)$('#speech').textContent='بچه: بابا نگاه کن! یه خرگوش کوچولو!';
|
||||
if(x>21&&storyReady(story)&&readyForPicnic(state)&&!state.busy&&!state.finished)finish();
|
||||
if(payment.phase!=='idle'){
|
||||
paymentTime+=dt;
|
||||
if(payment.phase==='approaching'&&paymentTime>1.2){payment.phase='ready';$('#swipe-card').disabled=false;$('#cancel-payment').hidden=false;$('#payment-result').textContent='دکمهٔ «کشیدن کارت» یا کلید C را بزن.';}
|
||||
if(payment.phase==='approved'){
|
||||
bankCard.position.set(.92-Math.min(paymentTime,1)*.35,1.86-Math.min(paymentTime,1)*.32,.78);
|
||||
if(paymentTime>1.35){const product=payment.product;payment=createPayment();$('#payment-panel').hidden=true;bankCard.visible=false;deliver(product);}
|
||||
}
|
||||
}
|
||||
if(deliveryTime>0){
|
||||
deliveryTime=Math.max(0,deliveryTime-dt);const t=3.4-deliveryTime;
|
||||
const slotIndex=(activeProduct?.channel??22)-1;
|
||||
const start=new THREE.Vector3(-.65+(slotIndex%10)*.115,.965+(5-Math.floor(slotIndex/10))*.275,.76);machine.localToWorld(start);
|
||||
const slot=new THREE.Vector3(-.13,.35,.86);machine.localToWorld(slot);
|
||||
const hand=new THREE.Vector3(.5,.65,-.15);family.localToWorld(hand);
|
||||
if(t<1.1)delivery.position.lerpVectors(start,slot,t/1.1);
|
||||
else if(t<1.8){delivery.position.copy(slot);}
|
||||
else delivery.position.lerpVectors(slot,hand,Math.min(1,(t-1.8)/1.3));
|
||||
flap.rotation.x=t>1&&t<2.6?-1:0;
|
||||
if(!deliveryTime){state.busy=false;delivery.visible=false;flap.rotation.x=0;tone(980,.18);syncHUD();}
|
||||
}
|
||||
lostPaper.visible=!story.done.includes('paper');lostPaper.rotation.y=Math.sin(elapsed)*.15;
|
||||
fishes.forEach((fish,i)=>fish.position.z=Math.sin(elapsed*.8+i)*.8);
|
||||
trees.forEach((tree,i)=>{tree.rotation.z=Math.sin(elapsed*.65+i)*.012;});
|
||||
butterfly.position.set(x+1.6+Math.sin(elapsed)*.5,2+Math.sin(elapsed*1.4)*.4,pathZ(x)+1.8);wings.forEach((wing,i)=>wing.rotation.z=Math.sin(elapsed*15)*(i?1:-1)*.7);
|
||||
if(soundEnabled&&elapsed>nextBird){tone(1500+Math.random()*900,.2,.018);nextBird=elapsed+3+Math.random()*4;}
|
||||
renderer.render(scene,camera);
|
||||
}
|
||||
$('#loading').remove();syncHUD();requestAnimationFrame(frame);
|
||||
@@ -0,0 +1,16 @@
|
||||
import {purchase} from './game-state.js';
|
||||
export function createPayment(){return {phase:'idle',product:null};}
|
||||
export function beginPayment(state,payment,product){
|
||||
if(payment.phase!=='idle'||!product||!purchase({...state,basket:[...state.basket]},product.id))return false;
|
||||
payment.product=product;payment.phase='approaching';state.busy=true;return true;
|
||||
}
|
||||
export function swipePayment(state,payment){
|
||||
if(payment.phase!=='ready')return false;
|
||||
state.busy=false;
|
||||
if(!purchase(state,payment.product.id)){payment.phase='idle';payment.product=null;return false;}
|
||||
payment.phase='approved';return true;
|
||||
}
|
||||
export function cancelPayment(state,payment){
|
||||
if(!['approaching','ready'].includes(payment.phase))return false;
|
||||
payment.phase='idle';payment.product=null;state.busy=false;return true;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export const chapters = {
|
||||
morning:{title:'تپهٔ آرزوها',lines:['پدر: بریم تپه؟ امروز راه رو با هم کشف میکنیم.']},
|
||||
rabbit:{title:'یک دوست کوچک',lines:['بچه: یه خرگوش! آروم نگاهش کنیم؟'],action:{value:'watch',label:'تماشای خرگوش'}},
|
||||
bridge:{title:'روی پل',lines:['پدر: دستم رو بگیر؛ با هم رد میشیم.']},
|
||||
fish:{title:'زیر پل',lines:['بچه: ماهیها رو ببین!']},
|
||||
courage:{title:'اولین خرید من',lines:['پدر: حالا برای پیکنیک خرید کنیم.']},
|
||||
wind:{title:'باد و نقاشی',lines:['بچه: نقاشیم رفت کنار بوته!']},
|
||||
paper:{title:'پیداش کردیم',lines:['بچه: سالمه! بالای تپه کاملش میکنم.'],action:{value:'recover',label:'برداشتن نقاشی'}},
|
||||
fork:{title:'مسیر تپه',lines:['پدر: از راه آفتابی به تپه میرسیم.'],action:{value:'sunny',label:'ادامهٔ مسیر'}},
|
||||
sunny:{title:'رنگهای جنگل',lines:['بچه: این رنگها رو توی نقاشیم میکشم!']},
|
||||
shade:{title:'درخت خاطرهها',lines:['پدر: بچه که بودم هم زیر همین درخت مینشستم.']},
|
||||
summit:{title:'کنار هم',lines:['بچه: دفعهٔ بعد، من راه رو نشونت میدم!'],action:{value:'gold',label:'کامل کردن نقاشی'}}
|
||||
};
|
||||
export function createStory(){return {done:[],rabbitChoice:null,route:null,sky:null};}
|
||||
export function completeChapter(story,id,choice){
|
||||
if(!chapters[id]||story.done.includes(id))return false;
|
||||
if(chapters[id].action&&chapters[id].action.value!==choice)return false;
|
||||
story.done.push(id);
|
||||
if(id==='rabbit')story.rabbitChoice=choice;
|
||||
if(id==='fork')story.route=choice;
|
||||
if(id==='summit')story.sky=choice;
|
||||
return true;
|
||||
}
|
||||
export function nextChapter(story,x){
|
||||
const done=id=>story.done.includes(id);
|
||||
if(!done('morning'))return 'morning';
|
||||
for(const [id,threshold] of [['rabbit',-3.7],['bridge',-2.2],['fish',0],['courage',2.7],['wind',12],['paper',14],['fork',16]]){
|
||||
if(!done(id))return x>=threshold?id:null;
|
||||
}
|
||||
if(!done(story.route))return x>=19?story.route:null;
|
||||
return null;
|
||||
}
|
||||
export function storyReady(story){return ['morning','rabbit','bridge','fish','courage','wind','paper','fork',story.route].every(id=>story.done.includes(id));}
|
||||
export function routeOffset(story,x){if(x>12&&x<15&&story.done.includes('wind'))return Math.sin((x-12)/3*Math.PI)*1.5;if(x<=16||x>=21||!story.route)return 0;return Math.sin((x-16)/5*Math.PI)*(story.route==='sunny'?2.2:-2.2);}
|
||||
export function discoveries(story){const entries=[];if(story.done.includes('rabbit'))entries.push(story.rabbitChoice==='sketch'?'🐇 نقاشی خرگوش':'🐇 تماشای آرام خرگوش');if(story.done.includes('fish'))entries.push('🐟 ماهیهای زیر پل');if(story.done.includes('courage'))entries.push('🌉 پل شجاعت');if(story.done.includes('paper'))entries.push('📄 نقاشی بازیابیشده');if(story.done.includes('sunny'))entries.push('🦋 رنگهای پروانهها');if(story.done.includes('shade'))entries.push('🌳 خاطرهٔ درخت قدیمی');return entries;}
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
font-family: Vazirmatn, Tahoma, sans-serif;
|
||||
color: #fff6df;
|
||||
background: #132e29;
|
||||
font-synthesis: none
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
button, a {
|
||||
-webkit-tap-highlight-color: transparent
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
button:focus-visible, a:focus-visible {
|
||||
outline: 3px solid #ffd774;
|
||||
outline-offset: 4px
|
||||
}
|
||||
|
||||
#world {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none
|
||||
}
|
||||
|
||||
header {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 5%;
|
||||
right: 5%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: #fff6df;
|
||||
text-decoration: none;
|
||||
font-size: 27px;
|
||||
font-weight: 900;
|
||||
line-height: 1
|
||||
}
|
||||
|
||||
.brand span {
|
||||
color: #bce598
|
||||
}
|
||||
|
||||
.brand small {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
letter-spacing: 2px;
|
||||
margin-top: 12px;
|
||||
font-weight: 400
|
||||
}
|
||||
|
||||
.wallet {
|
||||
border: 1px solid #ffffff30;
|
||||
background: #17372d90;
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 10px 20px;
|
||||
border-radius: 30px;
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.wallet strong {
|
||||
font-size: 23px
|
||||
}
|
||||
|
||||
.wallet span {
|
||||
font-size: 12px;
|
||||
color: #d1dacb
|
||||
}
|
||||
|
||||
.intro {
|
||||
position: absolute;
|
||||
top: 19%;
|
||||
right: 6%;
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #d4e9a9;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(44px, 6vw, 88px);
|
||||
line-height: 1.3;
|
||||
margin: 12px 0;
|
||||
font-weight: 900
|
||||
}
|
||||
|
||||
h1 em {
|
||||
font-style: normal;
|
||||
color: #c5e9a4
|
||||
}
|
||||
|
||||
.intro p {
|
||||
color: #d7dfcf;
|
||||
font-size: 14px
|
||||
}
|
||||
|
||||
.mission {
|
||||
position: absolute;
|
||||
left: 5%;
|
||||
top: 140px;
|
||||
width: 230px;
|
||||
padding: 20px;
|
||||
background: #14352c70;
|
||||
border: 1px solid #ffffff20;
|
||||
border-radius: 18px;
|
||||
backdrop-filter: blur(10px)
|
||||
}
|
||||
|
||||
.mission span {
|
||||
font-size: 11px;
|
||||
color: #c5e0b0
|
||||
}
|
||||
|
||||
.mission p {
|
||||
font-size: 14px
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 3px;
|
||||
background: #ffffff20;
|
||||
border-radius: 5px;
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.progress i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: #d8eda5;
|
||||
transition: width .2s
|
||||
}
|
||||
|
||||
#speech {
|
||||
position: absolute;
|
||||
bottom: 24%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #fff8e9;
|
||||
color: #29453b;
|
||||
padding: 13px 22px;
|
||||
border-radius: 18px 18px 3px 18px;
|
||||
font-size: 14px;
|
||||
max-width: 85%;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 40px #0002
|
||||
}
|
||||
|
||||
#interact {
|
||||
position: absolute;
|
||||
bottom: 15%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #d6eda5;
|
||||
color: #233f32;
|
||||
border: 0;
|
||||
border-radius: 40px;
|
||||
padding: 12px 25px;
|
||||
box-shadow: 0 6px 30px #0002
|
||||
}
|
||||
|
||||
#interact span {
|
||||
background: #284c37;
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
margin-right: 10px
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 5%;
|
||||
left: 5%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #e5edda;
|
||||
font-size: 11px
|
||||
}
|
||||
|
||||
footer button {
|
||||
background: transparent;
|
||||
border: 1px solid #ffffff40;
|
||||
border-radius: 30px;
|
||||
color: inherit;
|
||||
padding: 8px 16px
|
||||
}
|
||||
|
||||
.touch {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
left: 5%;
|
||||
display: flex;
|
||||
direction: ltr;
|
||||
gap: 12px
|
||||
}
|
||||
|
||||
.touch button {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border: 1px solid #ffffff50;
|
||||
background: #123b2d80;
|
||||
color: #fff8e9;
|
||||
border-radius: 50%;
|
||||
font-size: 25px;
|
||||
touch-action: none;
|
||||
user-select: none
|
||||
}
|
||||
|
||||
.touch button:active {
|
||||
background: #b8db85;
|
||||
color: #123b2d
|
||||
}
|
||||
|
||||
dialog {
|
||||
width: min(480px, 92vw);
|
||||
border: 1px solid #d9e6c4;
|
||||
border-radius: 25px;
|
||||
padding: 32px;
|
||||
background: #fcf9ee;
|
||||
color: #254334;
|
||||
box-shadow: 0 24px 100px #0006
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: #071f1db0;
|
||||
backdrop-filter: blur(7px)
|
||||
}
|
||||
|
||||
dialog .eyebrow {
|
||||
color: #64854d
|
||||
}
|
||||
|
||||
dialog h2 {
|
||||
font-size: 30px;
|
||||
margin: 12px 0
|
||||
}
|
||||
|
||||
dialog p {
|
||||
font-size: 13px;
|
||||
color: #687765
|
||||
}
|
||||
|
||||
dialog small {
|
||||
font-size: 11px;
|
||||
color: #788370
|
||||
}
|
||||
|
||||
#close {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 15px;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: 27px;
|
||||
color: #486143
|
||||
}
|
||||
|
||||
#products {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 22px 0
|
||||
}
|
||||
|
||||
#products button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
text-align: right;
|
||||
padding: 15px;
|
||||
border-radius: 13px;
|
||||
border: 1px solid #dfe5d2;
|
||||
background: #f0f2e4;
|
||||
color: #254334
|
||||
}
|
||||
|
||||
#products button:disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed
|
||||
}
|
||||
|
||||
#products b {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
#products .icon {
|
||||
font-size: 30px
|
||||
}
|
||||
|
||||
#shop-status {
|
||||
min-height: 24px
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #163c2f;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 9
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
header {
|
||||
top: 22px
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 22px
|
||||
}
|
||||
|
||||
.intro {
|
||||
top: 125px;
|
||||
right: 6%
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: 48px
|
||||
}
|
||||
|
||||
.mission {
|
||||
top: auto;
|
||||
bottom: 160px;
|
||||
left: 5%;
|
||||
width: 190px;
|
||||
padding: 12px
|
||||
}
|
||||
|
||||
.mission p {
|
||||
margin: 8px 0;
|
||||
font-size: 12px
|
||||
}
|
||||
|
||||
#speech {
|
||||
bottom: 31%;
|
||||
width: max-content;
|
||||
font-size: 12px
|
||||
}
|
||||
|
||||
#interact {
|
||||
bottom: 24%;
|
||||
white-space: nowrap
|
||||
}
|
||||
|
||||
footer p {
|
||||
max-width: 170px;
|
||||
font-size: 10px
|
||||
}
|
||||
|
||||
.intro p {
|
||||
font-size: 12px
|
||||
}
|
||||
|
||||
.wallet {
|
||||
padding: 6px 14px
|
||||
}
|
||||
|
||||
.touch {
|
||||
bottom: 85px
|
||||
}
|
||||
}
|
||||
#basket{font-size:12px;margin:12px 0;color:#e6eccb;line-height:1.8}#products small{display:block;margin-top:4px;font-weight:400}#celebrate{position:absolute;bottom:16%;left:50%;transform:translateX(-50%);border:0;border-radius:30px;padding:13px 24px;background:#d6eda5;color:#233f32;white-space:nowrap}#ending button{display:block;width:100%;padding:12px;margin-top:12px;border:1px solid #b7c69a;border-radius:14px;background:#e1edc7;color:#254334}#ending-basket{font-size:32px;margin:20px 0}#badge{color:#39735b;font-weight:700}dialog{max-height:85dvh;overflow-y:auto}footer{gap:10px}footer p{margin-left:auto}body:has(#shop[open]) .touch,body:has(#ending[open]) .touch{visibility:hidden}@media(max-width:650px){.intro{top:105px}.intro h1{font-size:37px;margin:5px 0}.intro p{display:none}.mission{top:205px;bottom:auto;width:190px;left:auto;right:6%;padding:10px}.mission p{font-size:11px}#basket{margin:4px 0;font-size:11px}#speech{bottom:29%;max-width:92%;padding:10px 14px}#interact{bottom:21%}footer{flex-wrap:wrap;bottom:15px}footer p{width:100%;max-width:none;margin:0}footer button{padding:6px 12px}.touch{bottom:100px}#celebrate{bottom:20%;font-size:12px}}
|
||||
#story-next{position:absolute;bottom:15%;left:50%;transform:translateX(-50%);border:1px solid #d9edb7;border-radius:30px;padding:12px 25px;background:#d6eda5;color:#233f32}.cinematic .intro,.cinematic .mission{opacity:0;pointer-events:none}.intro,.mission{transition:opacity .5s}body:has(#shop[open]) #shop{margin-right:4%;width:min(390px,42vw)}@media(max-width:650px){body:has(#shop[open]) #shop{margin: auto 4% 18px;width:92%;max-height:45dvh;padding:18px}#shop h2{font-size:23px;margin:6px 0}#products{margin:10px 0;gap:6px}#products button{padding:9px}#shop>p{margin:8px 0}#story-next{bottom:20%}}
|
||||
.product-code-card{display:flex;gap:12px;align-items:center;padding:10px 14px;background:#f0f2e4;border:1px solid #dfe5d2;border-radius:12px}.product-code-card b{flex:1}.product-code-card small{display:block;color:#526d42;margin-top:3px}#code-form label{display:block;font-size:13px;margin:10px 0 7px}.code-entry{display:flex;gap:8px}.code-entry input{min-width:0;width:100%;padding:12px;border:1px solid #a5b895;border-radius:10px;font:inherit;direction:ltr;text-align:center;background:white;color:#254334}.code-entry button{flex-shrink:0;border:0;border-radius:10px;background:#365e43;color:white;padding:10px 14px}.code-entry input[aria-invalid=true]{border:2px solid #b54b3c}#shop-status{color:#86452f}@media(max-width:650px){body:has(#shop[open]) #shop{max-height:60dvh}.product-code-card{padding:6px 10px}.code-entry button{font-size:12px}}
|
||||
#story-dialog{width:min(540px,92vw);padding:30px}#chapter-title{font-size:24px}#chapter-line{font-size:17px;line-height:2;min-height:100px;color:#294b3b}#chapter-count{font-size:11px;color:#7d886f}#chapter-actions{display:flex;gap:10px;flex-wrap:wrap}#chapter-actions button{flex:1;border:0;background:#365e43;color:#fff8e9;padding:13px;border-radius:12px;min-width:140px}#notebook-close{float:left;border:0;background:none;font-size:27px}#notebook canvas,#memory{width:100%;height:auto;border-radius:12px;border:5px solid #e4dbbb;margin-top:15px}#discoveries,#ending-discoveries{line-height:2;font-size:13px;color:#456347}body:has(#story-dialog[open]) .touch,body:has(#notebook[open]) .touch{visibility:hidden}footer{font-size:10px}footer button{padding:7px 10px}@media(max-width:650px){#chapter-line{font-size:15px;min-height:90px}#story-dialog{padding:23px}footer{gap:6px}footer button{font-size:10px}.touch{bottom:112px}}
|
||||
#chapter-count{direction:ltr;text-align:right}
|
||||
/* Story text stays beside the scene and never captures focus or blocks walking. */
|
||||
#story-dialog{position:fixed;top:36%;right:4%;width:min(340px,32vw);max-height:48dvh;overflow:auto;margin:0;padding:20px;border:1px solid #e8e6d080;border-radius:18px;background:#fff9e9ed;color:#294b3b;box-shadow:0 10px 35px #17372d30;z-index:2;backdrop-filter:blur(10px)}#story-dialog[hidden]{display:none}#story-dialog .eyebrow{color:#6d8055}#chapter-title{font-size:17px;margin:8px 0}#chapter-line{font-size:14px;line-height:1.9;min-height:0;margin:12px 0}#chapter-count{margin:0;font-size:10px}#chapter-actions:empty{display:none}#chapter-actions button{min-width:100px;font-size:12px;padding:10px}body:has(#story-dialog:not([hidden])) #speech{visibility:hidden}@media(max-width:650px){#story-dialog{top:auto;bottom:27%;right:4%;width:min(310px,85vw);max-height:34dvh;padding:14px}#chapter-title{font-size:15px}#chapter-line{font-size:12px;margin:7px 0}body:has(#story-dialog:not([hidden])) .mission{opacity:0;pointer-events:none}}
|
||||
#controls-guide{position:fixed;left:5%;top:350px;width:230px;padding:15px 18px;border:1px solid #ffffff30;border-radius:16px;background:#173b2ad9;backdrop-filter:blur(8px);font-size:12px}#controls-guide strong{font-size:13px;color:#e1eebe}#controls-guide p{margin:9px 0}#controls-guide kbd{display:inline-block;min-width:23px;text-align:center;padding:2px 5px;border:1px solid #ffffff50;border-radius:5px;font:inherit;background:#ffffff12}#controls-guide small{font-size:10px;color:#d5e0c3}.touch{display:grid;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(2,46px);gap:7px;bottom:85px;left:5%}.touch button{width:46px;height:46px;font-size:23px}#zoom-in{grid-column:2;grid-row:1}#back{grid-column:1;grid-row:2}#zoom-out{grid-column:2;grid-row:2}#forward{grid-column:3;grid-row:2}#products{max-height:29dvh;overflow-y:auto;overscroll-behavior:contain;padding:2px}#code-form{position:relative}#shop{overscroll-behavior:contain}@media(max-width:650px){#controls-guide{top:100px;left:4%;width:155px;padding:10px;font-size:10px}#controls-guide p{margin:6px 0}#controls-guide strong{font-size:11px}#controls-guide small{font-size:9px}.intro{right:4%;max-width:47%}.intro h1{font-size:29px}.touch{bottom:105px;grid-template-columns:repeat(3,42px);grid-template-rows:repeat(2,42px)}.touch button{width:42px;height:42px}#products{max-height:20dvh}body:has(#shop[open]) #shop{max-height:70dvh}}
|
||||
@media(max-height:650px) and (min-width:651px){#controls-guide{top:110px}.mission{display:none}.touch{bottom:65px}}
|
||||
|
||||
/* White story card; automatic dialogue stays outside a modal. */
|
||||
#story-dialog{background:#fffdf5;color:#294b3b;text-align:right}#chapter-line{color:#294b3b;text-shadow:none}#chapter-title{color:#436044}#story-dialog>.eyebrow{display:block}#chapter-count{display:block}
|
||||
/* Dialogue occupies only the lower part of the scene. */
|
||||
#story-dialog{top:auto;bottom:19%;right:auto;left:50%;transform:translateX(-50%);width:min(660px,86vw);max-height:30dvh;padding:13px 20px;text-align:center}#chapter-line{margin:5px 0}#chapter-title{font-size:12px}#story-dialog>.eyebrow,#chapter-count{display:none}#speech{bottom:19%}#shop,body:has(#shop[open]) #shop{width:min(860px,94vw);margin:auto;max-height:90dvh;padding:24px}.currency-note{padding:10px 14px;border-radius:12px;background:#e9efda;line-height:1.8;color:#365039!important;font-size:12px!important}.vending-browser{display:grid;grid-template-columns:minmax(180px,.8fr) minmax(260px,1.2fr);gap:16px;direction:rtl}.vending-browser h3{font-size:13px;margin:8px 0}#channels{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;max-height:34dvh;overflow:auto;align-content:start;direction:ltr;padding:3px}#channels button{font:inherit;padding:8px 0;border:1px solid #bccaa8;background:#e8eedb;color:#244531;border-radius:7px}#channels button:disabled{opacity:.35;cursor:not-allowed}#channels button.selected,#products button.selected{background:#cce5af;border:2px solid #3f764c}#products{max-height:34dvh;margin:0;padding:3px}#products button{width:100%;margin-bottom:6px}#products .icon{font-size:23px}.product-code-card{font-size:12px}#code-form{margin-top:14px}.wallet{white-space:nowrap}@media(max-width:650px){#story-dialog{top:auto;bottom:25%;right:auto;left:50%;width:92vw;padding:10px 14px}body:has(#shop[open]) #shop{width:96vw;max-height:92dvh;margin:auto;padding:18px 12px}.vending-browser{grid-template-columns:minmax(100px,.65fr) minmax(170px,1.35fr);gap:8px}#channels{grid-template-columns:repeat(3,1fr);max-height:30dvh;gap:4px}#channels button{padding:7px 0;font-size:12px}#products{max-height:30dvh}.product-code-card{gap:5px;padding:8px!important;font-size:10px}#products .icon{display:none}.currency-note{font-size:10px!important}.vending-browser h3{font-size:11px}}
|
||||
#machine-note{position:fixed;right:4%;top:33%;padding:14px 18px;background:#fffdf2ed;color:#2b4b37;border-radius:14px;font-size:13px;line-height:1.9;max-width:250px}#machine-note[hidden]{display:none}#machine-note small{font-size:11px}@media(max-width:650px){#machine-note{top:auto;bottom:43%;right:4%;font-size:11px;padding:10px;max-width:210px}}
|
||||
#payment-panel{position:fixed;bottom:7%;right:4%;z-index:5;width:min(350px,92vw);border-radius:18px;padding:20px;background:#fffdf2;color:#294b3b;box-shadow:0 10px 50px #0003}#payment-panel[hidden]{display:none}#payment-panel h2{font-size:18px;margin:0 0 12px}#payment-panel p{font-size:13px;line-height:1.8}#payment-panel button{padding:11px 16px;border:0;border-radius:10px;background:#315e43;color:white;margin:5px}#payment-panel button:disabled{opacity:.5;cursor:wait}#payment-panel small{display:block;margin-top:10px;font-size:10px}body:has(#payment-panel:not([hidden])) #story-dialog,body:has(#payment-panel:not([hidden])) #speech,body:has(#payment-panel:not([hidden])) #machine-note{visibility:hidden}body:has(#payment-panel:not([hidden])) .touch{visibility:hidden}
|
||||
/* Contextual third-person interaction prompt, anchored to the reader in 3D. */
|
||||
#interact{position:fixed;bottom:auto;transform:translate(-50%,-50%);display:flex;align-items:center;gap:9px;padding:7px 12px 7px 7px;border:1px solid #fff9;border-radius:32px;background:#152c27bb;color:#fff9e9;backdrop-filter:blur(10px);box-shadow:0 3px 20px #0004;z-index:3;min-height:48px}#interact[hidden]{display:none}#interact .interact-key{display:grid;place-items:center;margin:0;width:34px;height:34px;border:2px solid #f2e0af;border-radius:50%;background:#fff0;color:#fff9e9;font-size:18px;font-weight:700}#interact b{font-size:12px;font-weight:500}.near-machine .intro{opacity:0;pointer-events:none}
|
||||
/* Frosted glass panels retain the forest behind them. */
|
||||
dialog,#payment-panel{background:linear-gradient(135deg,#ffffffc9,#e5f0dd9c);border:1px solid #fff9;backdrop-filter:blur(22px) saturate(140%);-webkit-backdrop-filter:blur(22px) saturate(140%);box-shadow:0 24px 80px #071e2440,inset 0 1px 0 #fffd;color:#173b2c}dialog::backdrop{background:#071d2340;backdrop-filter:blur(4px)}dialog p,dialog small{color:#284c3b}.currency-note{background:#ffffff50;border:1px solid #fff8}#channels button,#products button{background:#ffffff50;border-color:#ffffffa0;color:#153a2b}#channels button.selected,#products button.selected{background:#b9dca5b0;border-color:#41714e}#code-form input{background:#fff9;border-color:#fff;color:#153a2b}#payment-panel small{color:#284c3b}#notebook canvas,#memory{border-color:#fff6}#ending button{background:#e7f3d68c;border-color:#fff8}#machine-note{background:#f8fff0a6;border:1px solid #fffa;backdrop-filter:blur(14px)}@media(max-width:650px){#interact{bottom:auto;padding:6px 10px 6px 6px}#interact .interact-key{width:32px;height:32px}}
|
||||
Reference in New Issue
Block a user