feat(city): add plaza exercise system with health tracking

- Adds CityPlazas support class for deterministic public plaza generation with outdoor gym equipment
- Implements exercise and stop-exercise actions with proximity validation to equipment
- Introduces health (0-100) and exercise_count attributes to Character model
- Reduces hospital stay duration from two game days to one game hour
- Adds 3D gym equipment rendering (bench, bars, bike, treadmill) in city viewer
- Includes exercise state machine handling in world ticker with energy drain
- Adds comprehensive PlazaExerciseTest and updates HospitalAdmissionTest
- Provides localized UI strings for health HUD and exercise prompts
This commit is contained in:
2026-10-17 22:10:50 -03:00
parent 79ce2a1825
commit 090b530481
18 changed files with 848 additions and 21 deletions
+345
View File
@@ -194,6 +194,15 @@ let mayorSystem = null;
let meMesh = null;
let hospitalBusy = false;
let spawnApplied = false;
let plazas = [];
let gymEquipment = [];
let exercise = {
active: false,
equipmentId: null,
repTimer: 0,
animPhase: 0,
nearId: null,
};
function clearGroup(g) {
while (g.children.length) {
@@ -1779,6 +1788,314 @@ function initRiver(g) {
// ------------------------------------------------------------------
const layoutExtras = { bridgeRoad: null, crossRoad: null };
// ------------------------------------------------------------------
// Public plazas + outdoor gym equipment
// ------------------------------------------------------------------
function buildGymEquipment(kind, x, z) {
const g = new THREE.Group();
const metal = new THREE.MeshStandardMaterial({ color: 0x4b5563, roughness: 0.45, metalness: 0.6 });
const accent = new THREE.MeshStandardMaterial({ color: 0x2563eb, roughness: 0.5, metalness: 0.3 });
const pad = new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.9 });
if (kind === 'bench') {
const seat = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.12, 0.5), pad);
seat.position.y = 0.55;
g.add(seat);
[-0.6, 0.6].forEach(px => {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.55, 0.4), metal);
leg.position.set(px, 0.28, 0);
g.add(leg);
});
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8), metal);
bar.rotation.z = Math.PI / 2;
bar.position.set(0, 0.95, 0);
g.add(bar);
[-0.7, 0.7].forEach(px => {
const plate = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, 0.1, 10), accent);
plate.rotation.z = Math.PI / 2;
plate.position.set(px, 0.95, 0);
g.add(plate);
});
} else if (kind === 'bars') {
[-0.8, 0.8].forEach(px => {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 2.2, 8), metal);
post.position.set(px, 1.1, 0);
g.add(post);
});
const top = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8), metal);
top.rotation.z = Math.PI / 2;
top.position.set(0, 2.15, 0);
g.add(top);
const mid = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.8, 8), accent);
mid.rotation.z = Math.PI / 2;
mid.position.set(0, 1.5, 0);
g.add(mid);
} else if (kind === 'bike') {
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.0, 0.12), metal);
frame.position.set(0, 0.55, 0);
g.add(frame);
const seatB = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.1, 0.25), pad);
seatB.position.set(-0.15, 1.1, 0);
g.add(seatB);
const handle = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.08, 0.5), accent);
handle.position.set(0.3, 1.15, 0);
g.add(handle);
const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.35, 0.04, 8, 16), metal);
wheel.position.set(0.1, 0.4, 0);
g.add(wheel);
g.userData.spin = wheel;
} else if (kind === 'treadmill') {
const deck = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.12, 0.9), pad);
deck.position.set(0, 0.18, 0);
g.add(deck);
const railL = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.08, 0.08), metal);
railL.position.set(0, 0.7, 0.4);
g.add(railL);
const railR = railL.clone();
railR.position.z = -0.4;
g.add(railR);
const consoleBox = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.55, 0.7), accent);
consoleBox.position.set(1.1, 0.55, 0);
g.add(consoleBox);
}
const ring = new THREE.Mesh(
new THREE.RingGeometry(1.1, 1.45, 24),
new THREE.MeshBasicMaterial({ color: 0x22c55e, side: THREE.DoubleSide, transparent: true, opacity: 0.45 })
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.08;
g.add(ring);
g.userData.glowRing = ring;
g.position.set(x, 0, z);
g.userData.kind = kind;
g.userData.baseY = 0;
worldGroup.add(g);
// Soft collider so the player stands next to equipment without walking through the frame.
addCollider(x, 0, z, 1.4, 1.2, 1.4);
const item = { id: `${kind}-${x.toFixed(1)}-${z.toFixed(1)}`, kind, x, z, mesh: g };
gymEquipment.push(item);
return item;
}
function initPlazas(list) {
plazas = Array.isArray(list) ? list : [];
gymEquipment = [];
const grassMat = new THREE.MeshStandardMaterial({ color: 0x3f7d3a, roughness: 1 });
const pathMat = new THREE.MeshStandardMaterial({ color: 0xc4b8a0, roughness: 0.95 });
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x6b4423, roughness: 0.9 });
const leafMat = new THREE.MeshStandardMaterial({ color: 0x2f6b2a, roughness: 0.85 });
for (const p of plazas) {
const [px, pz] = p.position;
const [pw, pd] = p.size || [22, 22];
const lawn = new THREE.Mesh(new THREE.BoxGeometry(pw, 0.12, pd), grassMat);
lawn.position.set(px, 0.06, pz);
lawn.receiveShadow = true;
worldGroup.add(lawn);
// Cross paths through the plaza.
const pathA = new THREE.Mesh(new THREE.BoxGeometry(pw, 0.04, 2.2), pathMat);
pathA.position.set(px, 0.13, pz);
worldGroup.add(pathA);
const pathB = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.04, pd), pathMat);
pathB.position.set(px, 0.13, pz);
worldGroup.add(pathB);
// Trees on plaza corners.
const corners = [
[px - pw / 2 + 2.5, pz - pd / 2 + 2.5],
[px + pw / 2 - 2.5, pz - pd / 2 + 2.5],
[px - pw / 2 + 2.5, pz + pd / 2 - 2.5],
[px + pw / 2 - 2.5, pz + pd / 2 - 2.5],
];
corners.forEach(([tx, tz], i) => {
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.28, 1.6, 6), trunkMat);
trunk.position.set(tx, 0.9, tz);
trunk.castShadow = true;
worldGroup.add(trunk);
const crown = new THREE.Mesh(new THREE.SphereGeometry(1.1 + (i % 2) * 0.2, 10, 10), leafMat);
crown.position.set(tx, 2.4, tz);
crown.castShadow = true;
worldGroup.add(crown);
});
// Central fountain basin.
const basin = new THREE.Mesh(
new THREE.CylinderGeometry(2.2, 2.4, 0.45, 16),
new THREE.MeshStandardMaterial({ color: 0x9ca3af, roughness: 0.7 })
);
basin.position.set(px, 0.25, pz);
basin.castShadow = true;
worldGroup.add(basin);
const water = new THREE.Mesh(
new THREE.CylinderGeometry(1.9, 1.9, 0.1, 16),
new THREE.MeshStandardMaterial({ color: 0x38bdf8, roughness: 0.2, metalness: 0.3, transparent: true, opacity: 0.85 })
);
water.position.set(px, 0.42, pz);
worldGroup.add(water);
for (const eq of p.equipment || []) {
buildGymEquipment(eq.kind, eq.position[0], eq.position[1]);
}
}
}
function nearestGymEquipment(maxDist = 2.8) {
let best = null;
for (const eq of gymEquipment) {
const d = Math.hypot(player.pos.x - eq.x, player.pos.z - eq.z);
if (d <= maxDist && (!best || d < best.dist)) best = { eq, dist: d };
}
return best;
}
function setExercisePrompt(text) {
const el = document.getElementById('exercise-prompt');
if (!el) return;
if (!text) {
el.style.display = 'none';
return;
}
el.style.display = 'block';
el.textContent = text;
}
function animateExercise(dt) {
if (!exercise.active || !meMesh) return;
exercise.animPhase += dt * 6;
const u = meMesh.userData;
if (!u?.legs || !u?.arms) return;
const swing = Math.sin(exercise.animPhase) * 0.9;
const lift = Math.abs(Math.sin(exercise.animPhase));
u.legs[0].rotation.x = swing;
u.legs[1].rotation.x = -swing;
u.arms[0].rotation.x = -Math.PI / 2 + lift * 0.4;
u.arms[1].rotation.x = -Math.PI / 2 + lift * 0.4;
u.arms[0].rotation.z = -0.6 - lift * 0.5;
u.arms[1].rotation.z = 0.6 + lift * 0.5;
meMesh.position.y = lift * 0.08;
}
async function startExerciseSession(eq) {
if (!meCharacter || exercise.active) return;
exercise.active = true;
exercise.equipmentId = eq.id;
exercise.repTimer = 0;
meCharacter.state = 'exercising';
if (stateEl) stateEl.textContent = 'exercising';
flashCharMsg(i18n.exerciseStart || 'Exercising…');
cityAudio.play('work');
try {
const data = await postJson('/city/actions/exercise', {
pos_x: player.pos.x,
pos_z: player.pos.z,
});
if (data.character) {
updateCharacterHud(data.character);
if ((data.character.health ?? 100) >= 100) {
flashCharMsg(data.message || i18n.exerciseMaxHealth || 'Max health');
await stopExerciseSession(false);
}
}
} catch (e) {
exercise.active = false;
exercise.equipmentId = null;
flashCharMsg(e.message, true);
if (meCharacter) meCharacter.state = 'awake';
}
}
async function stopExerciseSession(showMsg = true) {
if (!exercise.active) return;
exercise.active = false;
exercise.equipmentId = null;
if (meMesh) meMesh.position.y = 0;
if (meMesh?.userData?.arms) {
meMesh.userData.arms.forEach(a => { a.rotation.z = 0; a.rotation.x = 0; });
}
try {
const data = await postJson('/city/actions/stop-exercise', {});
if (data.character) updateCharacterHud(data.character);
if (showMsg) flashCharMsg(i18n.exerciseStop || 'Stopped exercising');
} catch (_) { /* still leave local state */ }
if (meCharacter && meCharacter.state === 'exercising') meCharacter.state = 'awake';
}
function updateExercise(dt) {
if (isHospitalizedNow()) {
if (exercise.active) stopExerciseSession(false);
setExercisePrompt(null);
return;
}
const near = spawnApplied ? nearestGymEquipment(2.8) : null;
if (exercise.active) {
const still = near && near.eq.id === exercise.equipmentId
|| (gymEquipment.some(e => e.id === exercise.equipmentId)
&& Math.hypot(player.pos.x - (gymEquipment.find(e => e.id === exercise.equipmentId)?.x ?? 999),
player.pos.z - (gymEquipment.find(e => e.id === exercise.equipmentId)?.z ?? 999)) < 4);
// Moving with WASD cancels the session.
const moving = keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight'];
if (!still || moving) {
stopExerciseSession();
setExercisePrompt(null);
return;
}
setExercisePrompt(`🏃 ${i18n.exerciseActive || 'Exercising…'} · E ${i18n.exerciseStopHint || 'stop'}`);
animateExercise(dt);
exercise.repTimer += dt;
if (exercise.repTimer >= 1.0) {
exercise.repTimer = 0;
postJson('/city/actions/exercise', {
pos_x: player.pos.x,
pos_z: player.pos.z,
})
.then((data) => {
if (data.character) {
updateCharacterHud(data.character);
if (data.character.health >= 100) {
flashCharMsg(i18n.exerciseMaxHealth || 'Max health');
stopExerciseSession(false);
}
}
})
.catch((e) => {
flashCharMsg(e.message, true);
stopExerciseSession(false);
});
}
return;
}
if (near && meCharacter && meCharacter.state !== 'sleeping') {
setExercisePrompt(`🏋️ ${i18n.exerciseUse || 'Press E to exercise'} · ${near.eq.kind}`);
} else {
setExercisePrompt(null);
}
// Toggle with E.
if (keys['KeyE']) {
keys['KeyE'] = false;
if (exercise.active) {
stopExerciseSession();
} else if (near) {
startExerciseSession(near.eq);
}
}
}
function buildCity(data) {
clearGroup(worldGroup);
colliders.length = 0;
@@ -1797,6 +2114,9 @@ function buildCity(data) {
mayorSystem = null;
meMesh = null;
spawnApplied = false;
plazas = [];
gymEquipment = [];
exercise = { active: false, equipmentId: null, repTimer: 0, animPhase: 0, nearId: null };
const layout = data.layout || { roads: [], blocks: [] };
const stores = data.stores || [];
@@ -2110,6 +2430,9 @@ function buildCity(data) {
}
}
// ---- Public plazas with outdoor gym equipment ----
initPlazas(data.plazas || []);
// ---- Citizen characters (all characters in this city) ----
if (Array.isArray(data.characters)) {
for (const c of data.characters) {
@@ -2283,12 +2606,22 @@ function resolveCollision(px, py, pz) {
/**
* Detect vehicles driving into a stationary player and report the accident.
* Runs only after the city is fully built and the player spawn is applied.
* Players on public plazas (gym parks) are safe from traffic.
*/
function checkVehicleHitOnPlayer() {
if (!window.__city10?.ready) return;
if (!meCharacter || hospitalBusy) return;
if (meCharacter.state === 'hospital') return;
if (!spawnApplied) return;
if (exercise.active) return;
// Soft zone: plazas are fenced parks off the roadway.
if (plazas.some(p => {
const [px, pz] = p.position;
const [pw, pd] = p.size || [22, 22];
return Math.abs(player.pos.x - px) <= pw / 2 && Math.abs(player.pos.z - pz) <= pd / 2;
})) {
return;
}
const box = playerAABB(player.pos.x, player.pos.y, player.pos.z);
for (const c of vehicleBoxes()) {
if (overlaps(box, c)) {
@@ -2609,6 +2942,8 @@ const charMsgEl = document.getElementById('char-msg');
const hungerEl = document.getElementById('hud-hunger');
const thirstEl = document.getElementById('hud-thirst');
const energyEl = document.getElementById('hud-energy');
const healthEl = document.getElementById('hud-health');
const exerciseCountEl = document.getElementById('hud-exercise-count');
const moneyEl = document.getElementById('hud-money');
const stateEl = document.getElementById('hud-char-state');
const educationEl = document.getElementById('hud-education');
@@ -2623,6 +2958,8 @@ function updateCharacterHud(me) {
if (hungerEl) hungerEl.textContent = `${me.hunger}%`;
if (thirstEl) thirstEl.textContent = `${me.thirst}%`;
if (energyEl) energyEl.textContent = `${me.energy}%`;
if (healthEl) healthEl.textContent = `${me.health ?? 100}%`;
if (exerciseCountEl) exerciseCountEl.textContent = `${me.exercise_count ?? 0}`;
if (moneyEl) moneyEl.textContent = `$${Number(me.money).toFixed(2)}`;
if (stateEl) stateEl.textContent = me.state || '—';
if (educationEl) educationEl.textContent = `${me.education ?? 0}%`;
@@ -2907,6 +3244,7 @@ function animate() {
updatePlayer(dt);
checkVehicleHitOnPlayer();
updateExercise(dt);
updateCameraFromPlayer();
updateHUD();
syncPositionThrottled();
@@ -2965,6 +3303,13 @@ window.__city10 = {
trafficPhase: this.trafficPhase,
vehicles: vehicleSystem ? vehicleSystem.count() : 0,
vehicleKinds: vehicleSystem ? vehicleSystem.kinds() : { car: 0, bus: 0, moto: 0, bike: 0 },
plazas: plazas.length,
gymEquipment: gymEquipment.length,
exercising: exercise.active,
nearGym: (() => {
const n = spawnApplied ? nearestGymEquipment(4) : null;
return n ? { id: n.eq.id, kind: n.eq.kind, x: n.eq.x, z: n.eq.z, dist: +n.dist.toFixed(2) } : null;
})(),
pedestrians: pedestrianSystem ? pedestrianSystem.count() : 0,
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
airplanes: airplaneSystem ? airplaneSystem.count() : 0,