feat(city): add east shore beach leisure activity

Characters can now relax on the beach sand to recover energy and
health. The server clamps position to the shoreline, exposes beach
geometry in the city payload, and the client renders animated waves,
sand, stalls, chairs, and ambient NPCs.

Includes Beach support class, new beach action endpoint, localization
strings, and feature tests.
This commit is contained in:
2026-10-31 20:35:22 -03:00
parent 4b71a9d0ff
commit b8c31e7be4
12 changed files with 827 additions and 5 deletions
+537 -2
View File
@@ -237,6 +237,10 @@ let exercise = {
animPhase: 0,
nearId: null,
};
// Beach leisure (east shore): geometry from the payload + relax session.
let relax = { active: false, timer: 0, animPhase: 0 };
let beach = null;
let beachSystem = null;
let remoteCitizens = new Map(); // characterId -> mesh
let presenceTimer = null;
let vehicleHeadlights = [];
@@ -1801,6 +1805,11 @@ function buildHumanoid(agent, showLabel = true) {
mayor: { skin: 0xe0ac69, hair: 0x555555, shirt: 0x1a1a2e, pants: 0x111111, scale: 1.05 },
traffic_officer: { skin: 0xd2956b, hair: 0x1a1a1a, shirt: 0x2a4a2a, pants: 0x1a1a1a, scale: 1 },
crowd: { skin: 0xf1c27d, hair: 0x2a1a0a, shirt: [0x8a4a4a, 0x4a6a8a, 0x5a7a4a, 0x7a5a8a, 0x8a7a4a][Math.floor(Math.random() * 5)], pants: 0x333333, scale: 1 },
// Beach crowd: swimwear palette; lifeguards wear classic red/yellow.
bather: { skin: 0xf1c27d, hair: [0x2a1a0a, 0x553311, 0x111111, 0x886644][Math.floor(Math.random() * 4)], shirt: [0xd9534f, 0x5bc0de, 0xf0ad4e, 0x8e44ad, 0x2ecc71][Math.floor(Math.random() * 5)], pants: [0x2c5f8a, 0xc0392b, 0x111111][Math.floor(Math.random() * 3)], scale: 1 },
lifeguard: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xd92b2b, pants: 0xf2e14c, scale: 1 },
surfer: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x3fb6c9, pants: 0x1c1c1c, scale: 1 },
basket: { skin: 0xd2956b, hair: 0x111111, shirt: 0xe8802a, pants: 0xf2f2f2, scale: 1 },
};
const cfgc = configs[type] || configs.pedestrian;
const s = cfgc.scale;
@@ -2760,6 +2769,424 @@ function initPlazas(list) {
}
}
// ------------------------------------------------------------------
// Beach (east shore): sand, animated sea, food stalls, chairs, court,
// lifeguards, bathers, surfers and basket players.
// ------------------------------------------------------------------
/** Shared wave height — vertex animation and swimmers/surfers use it. */
function beachWaveY(x, z, t) {
return Math.sin(x * 0.16 + t * 1.5) * 0.3 + Math.sin(z * 0.11 + t * 0.9) * 0.2;
}
/**
* Build the whole east-beach scene from the payload geometry.
*
* @param {object} cfg beach geometry (sand_from/sand_to/sea_to/z_min/z_max)
* @returns {{update: Function, counts: Function}}
*/
function initBeach(cfg) {
const sandFrom = cfg.sand_from;
const sandTo = cfg.sand_to;
const seaTo = cfg.sea_to;
const zMin = cfg.z_min;
const zMax = cfg.z_max;
const midZ = (zMin + zMax) / 2;
const zDepth = zMax - zMin;
const group = new THREE.Group();
const counts = {
bathers: 0, swimmers: 0, surfers: 0, basket: 0,
lifeguards: 0, chairs: 0, umbrellas: 0, stalls: 0, towers: 0,
};
// ---- Sand + wet shoreline strip ----
const sandMat = new THREE.MeshStandardMaterial({ color: 0xe7d6a3, roughness: 1 });
const sand = new THREE.Mesh(new THREE.PlaneGeometry(sandTo - sandFrom, zDepth), sandMat);
sand.rotation.x = -Math.PI / 2;
sand.position.set((sandFrom + sandTo) / 2, 0.05, midZ);
sand.receiveShadow = true;
group.add(sand);
const wetMat = new THREE.MeshStandardMaterial({ color: 0xcbb982, roughness: 0.85, metalness: 0.08 });
const wet = new THREE.Mesh(new THREE.PlaneGeometry(6, zDepth), wetMat);
wet.rotation.x = -Math.PI / 2;
wet.position.set(sandTo - 3, 0.055, midZ);
group.add(wet);
// ---- Sea with animated waves (one mesh, vertex displacement) ----
const seaW = seaTo - sandTo;
const seaGeo = new THREE.PlaneGeometry(seaW, zDepth, 40, 56);
const seaMat = new THREE.MeshStandardMaterial({
color: 0x2f7fb5, roughness: 0.35, metalness: 0.15,
transparent: true, opacity: 0.94,
});
const seaMesh = new THREE.Mesh(seaGeo, seaMat);
seaMesh.rotation.x = -Math.PI / 2;
seaMesh.position.set(sandTo + seaW / 2, 0.09, midZ);
seaMesh.frustumCulled = false;
group.add(seaMesh);
const seaBase = Float32Array.from(seaGeo.attributes.position.array);
const seaWorldX = sandTo + seaW / 2;
// Foam line where the waves break (opacity pulses with the swell).
const foamMat = new THREE.MeshStandardMaterial({
color: 0xeef6fa, roughness: 0.9, transparent: true, opacity: 0.7,
});
const foam = new THREE.Mesh(new THREE.PlaneGeometry(2.6, zDepth), foamMat);
foam.rotation.x = -Math.PI / 2;
foam.position.set(sandTo + 1.2, 0.1, midZ);
group.add(foam);
// ---- Basketball court: painted slab + two hoops ----
const courtTex = canvasTexture(512, (ctx, s) => {
ctx.fillStyle = '#c9a06a';
ctx.fillRect(0, 0, s, s);
ctx.strokeStyle = '#f4efe3';
ctx.lineWidth = 6;
ctx.strokeRect(26, 26, s - 52, s - 52);
ctx.beginPath();
ctx.moveTo(26, s / 2);
ctx.lineTo(s - 26, s / 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(s / 2, s / 2, 58, 0, Math.PI * 2);
ctx.stroke();
// Keys at both ends.
const keyW = 110, keyH = 150;
ctx.strokeRect(s / 2 - keyW / 2, 26, keyW, keyH);
ctx.strokeRect(s / 2 - keyW / 2, s - 26 - keyH, keyW, keyH);
ctx.beginPath();
ctx.arc(s / 2, 26 + keyH, 58, 0, Math.PI);
ctx.stroke();
ctx.beginPath();
ctx.arc(s / 2, s - 26 - keyH, 58, Math.PI, Math.PI * 2);
ctx.stroke();
}, 1, 1);
const courtX = sandFrom + 24;
const courtZ = -95;
const court = new THREE.Mesh(
new THREE.PlaneGeometry(16, 26),
new THREE.MeshStandardMaterial({ map: courtTex, roughness: 0.95 })
);
court.rotation.x = -Math.PI / 2;
court.position.set(courtX, 0.065, courtZ);
court.receiveShadow = true;
group.add(court);
const poleMatBeach = new THREE.MeshStandardMaterial({ color: 0x5a5f66, roughness: 0.6, metalness: 0.4 });
const boardMat = new THREE.MeshStandardMaterial({ color: 0xf2e9d8, roughness: 0.7 });
const ringMat = new THREE.MeshStandardMaterial({ color: 0xe86a17, roughness: 0.5, metalness: 0.3 });
const addHoop = (zEnd, dir) => {
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.12, 3.2, 8), poleMatBeach);
pole.position.set(courtX, 1.6, zEnd);
pole.castShadow = true;
group.add(pole);
const board = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.05, 0.1), boardMat);
board.position.set(courtX, 3.05, zEnd - dir * 0.3);
board.castShadow = true;
group.add(board);
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.24, 0.035, 8, 20), ringMat);
ring.rotation.x = Math.PI / 2;
ring.position.set(courtX, 2.9, zEnd - dir * 0.75);
group.add(ring);
};
addHoop(courtZ - 13.5, 1);
addHoop(courtZ + 13.5, -1);
// ---- Food stalls (striped awnings) ----
const awningTex = canvasTexture(128, (ctx, s) => {
for (let i = 0; i < 8; i++) {
ctx.fillStyle = i % 2 ? '#d94b3a' : '#f7f2e6';
ctx.fillRect((i * s) / 8, 0, s / 8, s);
}
}, 2, 1);
const woodMat = new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 0.9 });
const woodTopMat = new THREE.MeshStandardMaterial({ color: 0x6e4526, roughness: 0.85 });
const awningMat = new THREE.MeshStandardMaterial({ map: awningTex, roughness: 0.85, side: THREE.DoubleSide });
const postMat = new THREE.MeshStandardMaterial({ color: 0xd8c9a8, roughness: 0.9 });
const addStall = (z) => {
const x = sandFrom + 5;
const counter = new THREE.Mesh(new THREE.BoxGeometry(3.6, 1.05, 2.2), woodMat);
counter.position.set(x, 0.53, z);
counter.castShadow = true;
counter.receiveShadow = true;
group.add(counter);
const top = new THREE.Mesh(new THREE.BoxGeometry(3.9, 0.08, 2.5), woodTopMat);
top.position.set(x, 1.1, z);
group.add(top);
for (const [ox, oz] of [[-1.6, -1], [1.6, -1], [-1.6, 1], [1.6, 1]]) {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.3, 6), postMat);
post.position.set(x + ox, 1.2, z + oz);
post.castShadow = true;
group.add(post);
}
const awning = new THREE.Mesh(new THREE.BoxGeometry(4.3, 0.1, 3.1), awningMat);
awning.position.set(x, 2.4, z);
awning.rotation.z = 0.06;
awning.castShadow = true;
group.add(awning);
// Crates of fruit on the counter.
const crate1 = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.28, 0.5),
new THREE.MeshStandardMaterial({ color: 0xd93b3b, roughness: 0.8 }));
crate1.position.set(x - 0.8, 1.28, z + 0.3);
group.add(crate1);
const crate2 = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.28, 0.5),
new THREE.MeshStandardMaterial({ color: 0xe8b21e, roughness: 0.8 }));
crate2.position.set(x + 0.7, 1.28, z - 0.2);
group.add(crate2);
counts.stalls++;
};
addStall(40);
addStall(78);
// ---- Beach chairs + umbrellas facing the sea ----
const chairMat = new THREE.MeshStandardMaterial({ color: 0xf3efe4, roughness: 0.9 });
const chairBackMat = new THREE.MeshStandardMaterial({ color: 0x4a90c2, roughness: 0.9 });
const addChair = (x, z) => {
const chair = new THREE.Group();
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.08, 1.7), chairMat);
seat.position.y = 0.32;
chair.add(seat);
const back = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.62, 0.07), chairBackMat);
back.position.set(0, 0.58, -0.85);
back.rotation.x = -0.35;
chair.add(back);
for (const [ox, oz] of [[-0.3, -0.7], [0.3, -0.7], [-0.3, 0.7], [0.3, 0.7]]) {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.3, 0.06), chairMat);
leg.position.set(ox, 0.15, oz);
chair.add(leg);
}
chair.position.set(x, 0.05, z);
chair.rotation.y = Math.PI / 2; // face the sea (+X)
chair.traverse(o => { if (o.isMesh) o.castShadow = true; });
group.add(chair);
counts.chairs++;
};
const umbrellaPoleMat = new THREE.MeshStandardMaterial({ color: 0xe8e2d2, roughness: 0.8 });
const addUmbrella = (x, z) => {
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 2.5, 6), umbrellaPoleMat);
pole.position.set(x, 1.25, z);
pole.castShadow = true;
group.add(pole);
const canopy = new THREE.Mesh(new THREE.ConeGeometry(1.6, 0.6, 10), awningMat);
canopy.position.set(x, 2.7, z);
canopy.castShadow = true;
group.add(canopy);
counts.umbrellas++;
};
const chairRowA = sandFrom + 34;
const chairRowB = sandFrom + 41;
[-30, -14, 2, 18].forEach(z => addChair(chairRowA, z));
[-22, 6, 34].forEach(z => addChair(chairRowB, z));
[-22, 10].forEach(z => addUmbrella(sandFrom + 37.5, z));
// ---- Lifeguard tower + guards ----
const towerX = sandTo - 7;
const towerZ = 16;
for (const [ox, oz] of [[-0.6, -0.6], [0.6, -0.6], [-0.6, 0.6], [0.6, 0.6]]) {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.08, 2.5, 6), poleMatBeach);
leg.position.set(towerX + ox, 1.25, towerZ + oz);
leg.castShadow = true;
group.add(leg);
}
const platform = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.12, 1.7), woodMat);
platform.position.set(towerX, 2.56, towerZ);
platform.castShadow = true;
group.add(platform);
const railMat = new THREE.MeshStandardMaterial({ color: 0xd94f3a, roughness: 0.8 });
for (const [w, d, ox, oz] of [[1.7, 0.06, 0, -0.82], [0.06, 1.7, -0.82, 0], [0.06, 1.7, 0.82, 0]]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(w, 0.5, d), railMat);
rail.position.set(towerX + ox, 2.95, towerZ + oz);
group.add(rail);
}
const roof = new THREE.Mesh(new THREE.BoxGeometry(2, 0.1, 2), awningMat);
roof.position.set(towerX, 4.05, towerZ);
roof.castShadow = true;
group.add(roof);
for (const [ox, oz] of [[-0.8, -0.8], [0.8, -0.8], [-0.8, 0.8], [0.8, 0.8]]) {
const p = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.35, 6), postMat);
p.position.set(towerX + ox, 3.4, towerZ + oz);
group.add(p);
}
counts.towers = 1;
// ---- Crowd: bathers, swimmers, surfers, basket players, lifeguards ----
const bathers = [];
[-124, -70, -20, 34, 92, 132].forEach((z, i) => {
const b = buildHumanoid({ type: 'bather', name: '' }, false);
const x = sandTo - (i % 3) * 1.4;
b.position.set(x, -0.28, z);
b.rotation.y = i % 2 === 0 ? Math.PI / 2 : Math.PI; // face sea / along shore
group.add(b);
bathers.push({ mesh: b, baseX: x, baseZ: z, phase: i * 1.7 });
counts.bathers++;
});
const swimmers = [];
[-58, 52].forEach((z, i) => {
const s = buildHumanoid({ type: 'bather', name: '' }, false);
s.position.set(sandTo + 12 + i * 6, -0.55, z);
s.rotation.y = Math.PI / 2;
group.add(s);
swimmers.push({ mesh: s, baseX: sandTo + 12 + i * 6, baseZ: z, phase: i * 2.4 });
counts.swimmers++;
});
const surfers = [];
[{ z: -44, phase: 0 }, { z: 74, phase: Math.PI }].forEach(sCfg => {
const rig = new THREE.Group();
const board = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.1, 2.3),
new THREE.MeshStandardMaterial({ color: 0xf5f2ea, roughness: 0.6 }));
board.position.y = 0.06;
rig.add(board);
const rider = buildHumanoid({ type: 'surfer', name: '' }, false);
rider.position.y = 0.1;
rig.add(rider);
rig.position.set(sandTo + 30, 0.2, sCfg.z);
group.add(rig);
surfers.push({ mesh: rig, baseX: sandTo + 30, baseZ: sCfg.z, phase: sCfg.phase, span: 26 });
counts.surfers++;
});
const basket = [];
const lanes = [courtX - 2.4, courtX, courtX + 2.4];
lanes.forEach((laneX, i) => {
const p = buildHumanoid({ type: 'basket', name: '' }, false);
const startZ = courtZ - 8 + i * 8;
p.position.set(laneX, 0.05, startZ);
group.add(p);
basket.push({
mesh: p, laneX,
z: startZ, dir: i % 2 ? 1 : -1,
speed: 3.4 + i * 0.5,
endA: courtZ - 11.5, endB: courtZ + 11.5,
});
counts.basket++;
});
const ball = new THREE.Mesh(new THREE.SphereGeometry(0.16, 12, 12),
new THREE.MeshStandardMaterial({ color: 0xe07b20, roughness: 0.75 }));
ball.castShadow = true;
group.add(ball);
const shot = { timer: 4, phase: 'follow', t: 0, from: new THREE.Vector3(), to: new THREE.Vector3(), target: 0 };
const towerGuard = buildHumanoid({ type: 'lifeguard', name: '' }, false);
towerGuard.position.set(towerX, 2.62, towerZ);
group.add(towerGuard);
counts.lifeguards++;
const patrolGuard = buildHumanoid({ type: 'lifeguard', name: '' }, false);
patrolGuard.position.set(sandTo - 9, 0.05, -60);
group.add(patrolGuard);
counts.lifeguards++;
worldGroup.add(group);
return {
counts: () => counts,
update(dt, time) {
// Waves: displace the sea plane along its normal (local Z).
const pos = seaGeo.attributes.position;
const arr = pos.array;
for (let i = 0; i < pos.count; i++) {
const lx = seaBase[i * 3];
const ly = seaBase[i * 3 + 1];
arr[i * 3 + 2] = beachWaveY(seaWorldX + lx, midZ - ly, time);
}
pos.needsUpdate = true;
foamMat.opacity = 0.55 + Math.sin(time * 1.7) * 0.2;
for (const b of bathers) {
b.mesh.position.y = -0.28 + Math.sin(time * 1.6 + b.phase) * 0.07;
b.mesh.position.x = b.baseX + Math.sin(time * 0.5 + b.phase) * 0.7;
b.mesh.rotation.y = Math.PI / 2 + Math.sin(time * 0.4 + b.phase) * 0.5;
}
for (const s of swimmers) {
s.mesh.position.y = -0.55 + Math.sin(time * 2 + s.phase) * 0.1;
s.mesh.position.z = s.baseZ + Math.sin(time * 0.35 + s.phase) * 5;
}
for (const s of surfers) {
const w = time * 0.14 + s.phase;
s.mesh.position.z = s.baseZ + Math.sin(w) * s.span;
s.mesh.position.x = s.baseX + Math.cos(w) * 5;
s.mesh.position.y = 0.14 + beachWaveY(s.mesh.position.x, s.mesh.position.z, time);
// Face the direction of travel (derivative of the path).
s.mesh.rotation.y = Math.atan2(
-Math.sin(w) * 5 * 0.14,
Math.cos(w) * s.span * 0.14
);
}
// Basket players patrol the court; the ball follows the lead
// player and takes an arc shot whenever they reach a hoop.
for (const p of basket) {
p.z += p.dir * p.speed * dt;
if (p.z >= p.endB) { p.z = p.endB; p.dir = -1; }
if (p.z <= p.endA) { p.z = p.endA; p.dir = 1; }
p.mesh.position.z = p.z;
p.mesh.rotation.y = p.dir > 0 ? 0 : Math.PI;
}
const lead = basket[shot.target % basket.length];
if (shot.phase === 'follow') {
ball.position.set(
lead.mesh.position.x + 0.45,
0.95 + Math.abs(Math.sin(time * 5)) * 0.5,
lead.mesh.position.z + 0.2
);
shot.timer -= dt;
const nearHoop = Math.abs(lead.z - lead.endA) < 1.6 || Math.abs(lead.z - lead.endB) < 1.6;
if (shot.timer <= 0 && nearHoop) {
const hoopZ = Math.abs(lead.z - lead.endA) < 1.6 ? lead.endA : lead.endB;
shot.phase = 'shoot';
shot.t = 0;
shot.from.copy(ball.position);
shot.to.set(courtX, 2.9, hoopZ + (hoopZ < courtZ ? 0.75 : -0.75));
shot.timer = 6 + Math.random() * 3;
} else if (shot.timer <= 0) {
shot.timer = 2; // retry soon when they reach an end
}
} else if (shot.phase === 'shoot') {
shot.t += dt / 1.1;
const t = Math.min(1, shot.t);
ball.position.lerpVectors(shot.from, shot.to, t);
ball.position.y += Math.sin(Math.PI * t) * 1.4;
if (t >= 1) {
shot.phase = 'return';
shot.t = 0;
shot.from.copy(ball.position);
shot.target = (shot.target + 1) % basket.length;
const next = basket[shot.target];
shot.to.set(next.mesh.position.x + 0.45, 1.1, next.mesh.position.z);
}
} else { // return/pass
shot.t += dt / 0.9;
const t = Math.min(1, shot.t);
ball.position.lerpVectors(shot.from, shot.to, t);
ball.position.y += Math.sin(Math.PI * t) * 0.8;
if (t >= 1) shot.phase = 'follow';
}
// Lifeguards: the tower guard scans the water, the other
// patrols the shoreline facing the sea.
towerGuard.rotation.y = Math.sin(time * 0.5) * 0.7;
patrolGuard.position.z = Math.sin(time * 0.16) * 70;
patrolGuard.position.x = sandTo - 9 + Math.sin(time * 0.3) * 1.5;
patrolGuard.rotation.y = Math.PI / 2 + Math.sin(time * 0.16) * 0.15;
},
};
}
function nearestGymEquipment(maxDist = 2.8) {
let best = null;
for (const eq of gymEquipment) {
@@ -2842,13 +3269,97 @@ async function stopExerciseSession(showMsg = true) {
if (meCharacter && meCharacter.state === 'exercising') meCharacter.state = 'awake';
}
// ------------------------------------------------------------------
// Beach relax session: sit on the sand and recover a bit per server tick.
// ------------------------------------------------------------------
function animateRelax(dt) {
relax.animPhase += dt;
if (!meMesh) return;
const u = meMesh.userData;
if (!u?.legs || !u?.arms) return;
// Sitting with legs stretched forward and gentle breathing.
u.legs[0].rotation.x = -1.35;
u.legs[1].rotation.x = -1.35;
u.arms[0].rotation.x = 0.25;
u.arms[1].rotation.x = 0.25;
u.arms[0].rotation.z = -0.18;
u.arms[1].rotation.z = 0.18;
meMesh.position.y = 0.42 + Math.sin(relax.animPhase * 1.6) * 0.02;
}
function stopRelaxSession() {
if (!relax.active) return;
relax.active = false;
if (meMesh) {
meMesh.position.y = 0;
if (meMesh.userData?.legs) meMesh.userData.legs.forEach(l => { l.rotation.x = 0; });
if (meMesh.userData?.arms) meMesh.userData.arms.forEach(ar => {
ar.rotation.x = 0;
ar.rotation.z = 0;
});
}
}
function startRelaxSession() {
if (!meCharacter || relax.active || exercise.active) return;
relax.active = true;
relax.timer = 0;
relax.animPhase = 0;
flashCharMsg(i18n.beachStart || 'Resting on the sand…');
cityAudio.play('ok');
}
function updateExercise(dt) {
if (isHospitalizedNow()) {
if (exercise.active) stopExerciseSession(false);
if (relax.active) stopRelaxSession();
setExercisePrompt(null);
return;
}
// ---- Beach leisure (east shore) ----
const onBeach = !!beach
&& player.pos.x >= beach.sand_from - 1
&& player.pos.x <= beach.sand_to + 2
&& player.pos.z >= beach.z_min
&& player.pos.z <= beach.z_max;
const isMoving = keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight'];
if (relax.active) {
if (isMoving || !onBeach) {
stopRelaxSession();
setExercisePrompt(null);
} else {
setExercisePrompt(`🏖️ ${i18n.beachActive || 'Relaxing at the beach'} · E ${i18n.exerciseStopHint || 'stop'}`);
animateRelax(dt);
relax.timer += dt;
if (relax.timer >= 1.5) {
relax.timer = 0;
postJson('/city/actions/beach', {
pos_x: player.pos.x,
pos_z: player.pos.z,
})
.then((data) => {
if (data.character) updateCharacterHud(data.character);
if (data.full) {
flashCharMsg(data.message);
stopRelaxSession();
}
})
.catch((e) => {
flashCharMsg(e.message, true);
stopRelaxSession();
});
}
if (keys['KeyE']) {
keys['KeyE'] = false;
stopRelaxSession();
}
return;
}
}
const near = spawnApplied ? nearestGymEquipment(2.8) : null;
if (exercise.active) {
@@ -2895,6 +3406,8 @@ function updateExercise(dt) {
if (near && meCharacter && meCharacter.state !== 'sleeping') {
setExercisePrompt(`🏋️ ${i18n.exerciseUse || 'Press E to exercise'} · ${near.eq.kind}`);
} else if (onBeach && meCharacter && meCharacter.state !== 'sleeping') {
setExercisePrompt(`🏖️ ${i18n.beachPrompt || 'Relax at the beach'} · E`);
} else {
setExercisePrompt(null);
}
@@ -2906,6 +3419,8 @@ function updateExercise(dt) {
stopExerciseSession();
} else if (near) {
startExerciseSession(near.eq);
} else if (onBeach && meCharacter && meCharacter.state !== 'sleeping') {
startRelaxSession();
}
}
}
@@ -2933,6 +3448,9 @@ function buildCity(data) {
plazas = [];
gymEquipment = [];
exercise = { active: false, equipmentId: null, repTimer: 0, animPhase: 0, nearId: null };
relax = { active: false, timer: 0, animPhase: 0 };
beach = null;
beachSystem = null;
const layout = data.layout || { roads: [], blocks: [] };
const stores = data.stores || [];
@@ -3259,6 +3777,10 @@ function buildCity(data) {
worldGroup.add(ring);
});
// ---- Beach (east shore) ----
beach = data.beach || null;
beachSystem = beach ? initBeach(beach) : null;
// ---- Filler buildings ----
// Fillers only exist on empty ground: skip cells near stores AND near
// any placed building (hospital, school, city hall, player houses) so
@@ -3801,9 +4323,15 @@ function updatePlayer(dt) {
player.onGround = false;
}
// City bounds, extended over the east beach: players may walk onto
// the sand and wade at the shoreline, but never into the deep sea.
let maxX = cityBounds ? cityBounds.max_x : 400;
if (beach && player.pos.z >= beach.z_min && player.pos.z <= beach.z_max) {
maxX = Math.max(maxX, beach.sand_to + 1);
}
player.pos.x = cityBounds
? Math.max(cityBounds.min_x, Math.min(cityBounds.max_x, player.pos.x))
: Math.max(-400, Math.min(400, player.pos.x));
? Math.max(cityBounds.min_x, Math.min(maxX, player.pos.x))
: Math.max(-400, Math.min(maxX, player.pos.x));
player.pos.z = cityBounds
? Math.max(cityBounds.min_z, Math.min(cityBounds.max_z, player.pos.z))
: Math.max(-400, Math.min(400, player.pos.z));
@@ -4441,6 +4969,7 @@ function animate() {
if (airplaneSystem) airplaneSystem.update(dt);
if (trainSystem) trainSystem.update(dt);
if (riverSystem) riverSystem.update(dt, simTimeAcc);
if (beachSystem) beachSystem.update(dt, simTimeAcc);
updatePlayer(dt);
checkVehicleHitOnPlayer();
@@ -4942,6 +5471,10 @@ window.__city10 = {
return out;
},
playerCrossingAllowed,
/** Current bump-to-talk peer (null when the chat is closed). */
get talkPeer() {
return currentPeer;
},
/** Local player pose for movement/look tests. */
playerPose() {
return {
@@ -4996,6 +5529,8 @@ window.__city10 = {
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
crosswalks: crosswalkCount,
sidewalkSegments: sidewalkSegmentCount,
beach: beachSystem ? beachSystem.counts() : null,
remoteIds: [...remoteCitizens.keys()],
render: {
calls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,