diff --git a/.playwright-mcp/city10-exercise-working.png b/.playwright-mcp/city10-exercise-working.png new file mode 100644 index 0000000..5521134 Binary files /dev/null and b/.playwright-mcp/city10-exercise-working.png differ diff --git a/.playwright-mcp/city10-plaza-exercise.png b/.playwright-mcp/city10-plaza-exercise.png new file mode 100644 index 0000000..035ef6a Binary files /dev/null and b/.playwright-mcp/city10-plaza-exercise.png differ diff --git a/app/Console/Commands/CityTick.php b/app/Console/Commands/CityTick.php index 9d77c8e..792d064 100755 --- a/app/Console/Commands/CityTick.php +++ b/app/Console/Commands/CityTick.php @@ -134,6 +134,13 @@ class CityTick extends Command $character->energy = max(0, (int) $character->energy - (int) round(0.8 * $elapsedMin)); break; + case Character::STATE_EXERCISING: + $character->energy = max(0, (int) $character->energy - (int) round(1.0 * $elapsedMin)); + if ((int) $character->energy <= 5) { + $character->state = Character::STATE_AWAKE; + } + break; + default: $drain = $starving ? 0.8 : 0.4; $character->energy = max(0, (int) $character->energy - (int) round($drain * $elapsedMin)); @@ -162,6 +169,13 @@ class CityTick extends Command return; } + // Offline exercise sessions end after the next tick so NPCs recover. + if ($character->state === Character::STATE_EXERCISING) { + $character->state = Character::STATE_AWAKE; + + return; + } + if ($character->energy < 20 && $character->house_id !== null) { $character->state = Character::STATE_SLEEPING; diff --git a/app/Http/Controllers/Web/CityActionController.php b/app/Http/Controllers/Web/CityActionController.php index 07aa01b..cccb711 100755 --- a/app/Http/Controllers/Web/CityActionController.php +++ b/app/Http/Controllers/Web/CityActionController.php @@ -9,6 +9,7 @@ use App\Models\City; use App\Models\InventoryItem; use App\Models\Job; use App\Models\Transaction; +use App\Support\CityPlazas; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -97,14 +98,90 @@ class CityActionController extends Controller 'sell' => $this->sell($request, $character), 'build' => $this->build($request, $character), 'apply-job' => $this->applyJob($request, $character), + 'exercise' => $this->exercise($request, $character), + 'stop-exercise' => $this->stopExercise($character), default => response()->json(['message' => __('city.action_unknown')], 422), }; } + /** + * One exercise rep on nearby plaza gym equipment: +health, −energy. + * + * Optional pos_x/pos_z let the client report its live position so the + * proximity check does not wait for the throttled position sync. + */ + private function exercise(Request $request, Character $character): JsonResponse + { + if ($character->state === Character::STATE_SLEEPING) { + return response()->json(['message' => __('city.exercise_need_awake')], 422); + } + + $data = $request->validate([ + 'pos_x' => ['nullable', 'numeric'], + 'pos_z' => ['nullable', 'numeric'], + ]); + + $x = array_key_exists('pos_x', $data) ? (float) $data['pos_x'] : (float) $character->pos_x; + $z = array_key_exists('pos_z', $data) ? (float) $data['pos_z'] : (float) $character->pos_z; + + $nearby = CityPlazas::nearestEquipment( + $character->city, + $x, + $z, + 4.0, + ); + + if ($nearby === null) { + return response()->json(['message' => __('city.exercise_need_equipment')], 422); + } + + if ((int) $character->energy < 5) { + return response()->json(['message' => __('city.exercise_tired')], 422); + } + + if ((int) $character->health >= 100) { + return response()->json([ + 'message' => __('city.exercise_max_health'), + 'character' => $this->characterData($character), + ]); + } + + $character->forceFill([ + 'state' => Character::STATE_EXERCISING, + 'health' => min(100, (int) $character->health + 2), + 'energy' => max(0, (int) $character->energy - 2), + 'exercise_count' => (int) $character->exercise_count + 1, + 'pos_x' => $x, + 'pos_z' => $z, + ])->save(); + + return response()->json([ + 'message' => __('city.exercise_ok'), + 'equipment' => $nearby, + 'character' => $this->characterData($character), + ]); + } + + /** + * Leave the exercising state (back to awake). + */ + private function stopExercise(Character $character): JsonResponse + { + if ($character->state === Character::STATE_EXERCISING) { + $character->state = Character::STATE_AWAKE; + $character->save(); + } + + return response()->json([ + 'message' => __('city.action_ok'), + 'character' => $this->characterData($character), + ]); + } + /** * Admit the character to the hospital after a vehicle collision. * - * Stays two game days (Character::HOSPITAL_STAY_SECONDS). + * Stays one game hour (Character::HOSPITAL_STAY_SECONDS). */ private function accident(Character $character): JsonResponse { @@ -483,7 +560,9 @@ class CityActionController extends Controller 'energy' => (int) $character->energy, 'hunger' => (int) $character->hunger, 'thirst' => (int) $character->thirst, + 'health' => (int) $character->health, 'education' => (int) $character->education, + 'exercise_count' => (int) $character->exercise_count, 'job_id' => $character->job_id, 'job_title' => $character->job?->title, 'house_id' => $character->house_id, diff --git a/app/Http/Controllers/Web/CityViewerController.php b/app/Http/Controllers/Web/CityViewerController.php index 70a4bf4..4c6bea4 100755 --- a/app/Http/Controllers/Web/CityViewerController.php +++ b/app/Http/Controllers/Web/CityViewerController.php @@ -9,6 +9,7 @@ use App\Models\Character; use App\Models\City; use App\Models\Job; use App\Models\Store; +use App\Support\CityPlazas; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -128,7 +129,9 @@ class CityViewerController extends Controller 'energy' => (int) $me->energy, 'hunger' => (int) $me->hunger, 'thirst' => (int) $me->thirst, + 'health' => (int) $me->health, 'education' => (int) $me->education, + 'exercise_count' => (int) $me->exercise_count, 'job_id' => $me->job_id, 'job_title' => $me->job?->title, 'house_id' => $me->house_id, @@ -137,6 +140,7 @@ class CityViewerController extends Controller 'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(), ] : null, 'layout' => $this->cityLayout($city, $stores->all()), + 'plazas' => CityPlazas::forCity($city), 'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [ 'id' => $a->id, 'name' => $a->name, diff --git a/app/Models/Character.php b/app/Models/Character.php index 7d6f649..a667f8b 100755 --- a/app/Models/Character.php +++ b/app/Models/Character.php @@ -17,8 +17,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; */ #[Fillable([ 'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt', - 'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', - 'state', 'job_id', 'house_id', 'education', 'is_online', 'last_tick_at', + 'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health', + 'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at', 'hospitalized_until', ])] class Character extends Model @@ -34,6 +34,13 @@ class Character extends Model public const STATE_STUDYING = 'studying'; + /** + * Character is exercising on public plaza gym equipment. + * + * @var string + */ + public const STATE_EXERCISING = 'exercising'; + /** * Character admitted to the hospital after a traffic accident. * @@ -42,11 +49,14 @@ class Character extends Model public const STATE_HOSPITAL = 'hospital'; /** - * Hospital stay length in seconds (two game days of 240s each). + * Hospital stay length in seconds (one game hour). + * + * The day/night cycle is 240 real seconds per 24 game hours, so one + * game hour equals 10 real seconds. * * @var int */ - public const HOSPITAL_STAY_SECONDS = 480; + public const HOSPITAL_STAY_SECONDS = 10; /** * Default hospital bed coordinates used when no hospital building exists. @@ -153,8 +163,10 @@ class Character extends Model 'energy' => 100, 'hunger' => 100, 'thirst' => 100, + 'health' => 100, 'state' => self::STATE_AWAKE, 'education' => 0, + 'exercise_count' => 0, 'is_online' => false, ]); @@ -189,7 +201,9 @@ class Character extends Model 'energy' => 'integer', 'hunger' => 'integer', 'thirst' => 'integer', + 'health' => 'integer', 'education' => 'integer', + 'exercise_count' => 'integer', 'is_online' => 'boolean', 'last_tick_at' => 'datetime', 'hospitalized_until' => 'datetime', @@ -207,7 +221,7 @@ class Character extends Model } /** - * Admit the character to the hospital for two game days after an accident. + * Admit the character to the hospital for one game hour after an accident. * * Teleports them to the city hospital bed and returns the bed position. * @@ -236,7 +250,7 @@ class Character extends Model } /** - * Release the character from the hospital when the stay has elapsed. + * Release the character from the hospital when the one-hour stay has elapsed. */ public function releaseIfHospitalStayOver(): bool { diff --git a/app/Support/CityPlazas.php b/app/Support/CityPlazas.php new file mode 100755 index 0000000..fdc3a4e --- /dev/null +++ b/app/Support/CityPlazas.php @@ -0,0 +1,142 @@ + + */ + public const EQUIPMENT_KINDS = ['bench', 'bars', 'bike', 'treadmill']; + + /** + * Build plaza + equipment layout for a city. + * + * @return list}> + */ + public static function forCity(City $city): array + { + $minX = (int) floor(((float) $city->bounds_min_x) / 40) * 40 - 40; + $maxX = (int) ceil(((float) $city->bounds_max_x) / 40) * 40 + 40; + $minZ = (int) floor(((float) $city->bounds_min_z) / 40) * 40 - 40; + $maxZ = (int) ceil(((float) $city->bounds_max_z) / 40) * 40 + 40; + + $occupied = Store::query() + ->where(function ($q): void { + // Stores are global for now; keep city filter ready for multi-city. + $q->whereNotNull('id'); + }) + ->get(['id', 'pos_x', 'pos_z']) + ->map(fn ($s): array => ['x' => (float) $s->pos_x, 'z' => (float) $s->pos_z]) + ->all(); + + $occupied = array_merge($occupied, Building::query() + ->where('city_id', $city->id) + ->get(['id', 'pos_x', 'pos_z', 'type']) + ->map(fn ($b): array => ['x' => (float) $b->pos_x, 'z' => (float) $b->pos_z]) + ->all()); + + $plazas = []; + $index = 0; + for ($x = $minX; $x < $maxX; $x += 40) { + for ($z = $minZ; $z < $maxZ; $z += 40) { + $cx = $x + 20.0; + $cz = $z + 20.0; + + $nearOccupied = array_reduce($occupied, function (bool $carry, array $o) use ($cx, $cz): bool { + return $carry || (abs($o['x'] - $cx) < 25 && abs($o['z'] - $cz) < 25); + }, false); + + // Keep a sparse, deterministic plaza pattern (~1 free block in 4). + if ($nearOccupied || ($index % 4) !== 1) { + $index++; + + continue; + } + $index++; + + $equipment = []; + foreach (self::EQUIPMENT_KINDS as $k => $kind) { + $angle = ($k / count(self::EQUIPMENT_KINDS)) * M_PI * 2; + $ex = round($cx + cos($angle) * 8, 2); + $ez = round($cz + sin($angle) * 8, 2); + $equipment[] = [ + 'id' => sprintf('plaza-%d-%s', count($plazas), $kind), + 'kind' => $kind, + 'position' => [$ex, $ez], + ]; + } + + $plazas[] = [ + 'id' => 'plaza-'.count($plazas), + 'position' => [$cx, $cz], + 'size' => [22.0, 22.0], + 'equipment' => $equipment, + ]; + } + } + + // Guarantee at least one plaza near the city center for newcomers. + if ($plazas === []) { + $cx = 20.0; + $cz = -20.0; + $equipment = []; + foreach (self::EQUIPMENT_KINDS as $k => $kind) { + $angle = ($k / count(self::EQUIPMENT_KINDS)) * M_PI * 2; + $equipment[] = [ + 'id' => 'plaza-0-'.$kind, + 'kind' => $kind, + 'position' => [round($cx + cos($angle) * 8, 2), round($cz + sin($angle) * 8, 2)], + ]; + } + $plazas[] = [ + 'id' => 'plaza-0', + 'position' => [$cx, $cz], + 'size' => [22.0, 22.0], + 'equipment' => $equipment, + ]; + } + + return $plazas; + } + + /** + * Find the nearest gym equipment within reach of a world position. + * + * @return array{plaza_id: string, kind: string, position: array{0: float, 1: float}, distance: float}|null + */ + public static function nearestEquipment(City $city, float $x, float $z, float $maxDistance = 3.0): ?array + { + $best = null; + foreach (self::forCity($city) as $plaza) { + foreach ($plaza['equipment'] as $eq) { + $dx = $eq['position'][0] - $x; + $dz = $eq['position'][1] - $z; + $dist = hypot($dx, $dz); + if ($dist <= $maxDistance && ($best === null || $dist < $best['distance'])) { + $best = [ + 'plaza_id' => $plaza['id'], + 'kind' => $eq['kind'], + 'position' => $eq['position'], + 'distance' => $dist, + ]; + } + } + } + + return $best; + } +} diff --git a/database/migrations/2026_10_18_001000_add_hospitalized_until_to_characters_table.php b/database/migrations/2026_10_18_001000_add_hospitalized_until_to_characters_table.php old mode 100644 new mode 100755 index a8148d3..220c4dd --- a/database/migrations/2026_10_18_001000_add_hospitalized_until_to_characters_table.php +++ b/database/migrations/2026_10_18_001000_add_hospitalized_until_to_characters_table.php @@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Schema; /** * Adds hospital admission tracking to characters. * - * When a character is hit by a vehicle they are admitted for two game days + * When a character is hit by a vehicle they are admitted for one game hour * (hospitalized_until). The world ticker releases them automatically. */ return new class extends Migration diff --git a/database/migrations/2026_10_18_002000_add_health_to_characters_table.php b/database/migrations/2026_10_18_002000_add_health_to_characters_table.php new file mode 100755 index 0000000..d3c4d1c --- /dev/null +++ b/database/migrations/2026_10_18_002000_add_health_to_characters_table.php @@ -0,0 +1,35 @@ +unsignedTinyInteger('health')->default(100)->after('thirst'); + $table->unsignedInteger('exercise_count')->default(0)->after('education'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('characters', function (Blueprint $table): void { + $table->dropColumn(['health', 'exercise_count']); + }); + } +}; diff --git a/database/seeders/CityWorldSeeder.php b/database/seeders/CityWorldSeeder.php index 89e4725..f218436 100755 --- a/database/seeders/CityWorldSeeder.php +++ b/database/seeders/CityWorldSeeder.php @@ -56,7 +56,7 @@ class CityWorldSeeder extends Seeder ['salary' => 14, 'requires_education' => 60], ); - // City hospital: accident victims are admitted here for two game days. + // City hospital: accident victims are admitted here for one game hour. Building::updateOrCreate( [ 'city_id' => $city->id, @@ -83,5 +83,9 @@ class CityWorldSeeder extends Seeder User::query()->where('status', User::STATUS_ACTIVE)->each(function (User $user) use ($city): void { Character::ensureForUser($user, $city); }); + + // Backfill fitness columns for characters created before the migration. + Character::query()->whereNull('health')->update(['health' => 100]); + Character::query()->whereNull('exercise_count')->update(['exercise_count' => 0]); } } diff --git a/lang/en.json b/lang/en.json index 0b753d2..74788d4 100755 --- a/lang/en.json +++ b/lang/en.json @@ -192,7 +192,7 @@ "city.citizens_near": "Citizens", "city.apply_job": "Apply for", "city.buy_hint": "Buys 1 food + 1 water + 10 materials", - "city.hospital_hit": "You were hit by a vehicle! Hospitalized for 2 days.", + "city.hospital_hit": "You were hit by a vehicle! Hospitalized for 1 hour.", "city.hospital_already": "You are already in the hospital.", "city.hospital_blocked": "You are hospitalized and cannot do that right now.", "city.hospital_bed": "Hospitalized", @@ -204,5 +204,17 @@ "city.vol_music": "Music", "city.vol_sfx": "Effects", "city.mute": "Mute", - "city.audio_hint": "Volume saved on this device." + "city.audio_hint": "Volume saved on this device.", + "city.hud_health": "Health", + "city.hud_exercise": "Workouts", + "city.exercise_use": "Press E to exercise", + "city.exercise_active": "Exercising", + "city.exercise_stopHint": "to stop", + "city.exercise_ok": "Nice! +2 health.", + "city.exercise_need_equipment": "Get closer to plaza gym equipment.", + "city.exercise_need_awake": "You must be awake to exercise.", + "city.exercise_tired": "You are too tired to exercise.", + "city.exercise_max_health": "Health is already at maximum!", + "city.exerciseStart": "Starting exercise…", + "city.exerciseStop": "Exercise stopped." } diff --git a/lang/es.json b/lang/es.json index 7ce26e7..f7d5abf 100755 --- a/lang/es.json +++ b/lang/es.json @@ -192,7 +192,7 @@ "city.citizens_near": "Ciudadanos", "city.apply_job": "Solicitar", "city.buy_hint": "Compra 1 comida + 1 agua + 10 materiales", - "city.hospital_hit": "¡Te atropelló un vehículo! Internado en el hospital por 2 días.", + "city.hospital_hit": "¡Te atropelló un vehículo! Internado en el hospital por 1 hora.", "city.hospital_already": "Ya está internado en el hospital.", "city.hospital_blocked": "Está internado y no puede hacer eso ahora.", "city.hospital_bed": "Internado en el hospital", @@ -204,5 +204,17 @@ "city.vol_music": "Música", "city.vol_sfx": "Efectos", "city.mute": "Silenciar", - "city.audio_hint": "Volumen guardado en este dispositivo." + "city.audio_hint": "Volumen guardado en este dispositivo.", + "city.hud_health": "Salud", + "city.hud_exercise": "Ejercicios", + "city.exercise_use": "Pulse E para ejercitarse", + "city.exercise_active": "Ejercitándose", + "city.exercise_stopHint": "para parar", + "city.exercise_ok": "¡Bien! +2 de salud.", + "city.exercise_need_equipment": "Acérquese a un equipo de la plaza.", + "city.exercise_need_awake": "Debe estar despierto para ejercitarse.", + "city.exercise_tired": "Está demasiado cansado para ejercitarse.", + "city.exercise_max_health": "¡La salud ya está en el máximo!", + "city.exerciseStart": "Empezando a ejercitarse…", + "city.exerciseStop": "Ejercicio terminado." } diff --git a/lang/pt_BR.json b/lang/pt_BR.json index 6226cd2..9b47375 100755 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -192,7 +192,7 @@ "city.citizens_near": "Cidadãos", "city.apply_job": "Aplicar para", "city.buy_hint": "Compra 1 comida+1 água+10 materiais", - "city.hospital_hit": "Você foi atropelado! Internado no hospital por 2 dias.", + "city.hospital_hit": "Você foi atropelado! Internado no hospital por 1 hora.", "city.hospital_already": "Você já está internado no hospital.", "city.hospital_blocked": "Você está internado no hospital e não pode fazer isso agora.", "city.hospital_bed": "Internado no hospital", @@ -204,5 +204,17 @@ "city.vol_music": "Música", "city.vol_sfx": "Efeitos", "city.mute": "Mudo", - "city.audio_hint": "Volume salvo neste dispositivo." + "city.audio_hint": "Volume salvo neste dispositivo.", + "city.hud_health": "Saúde", + "city.hud_exercise": "Exercícios", + "city.exercise_use": "Pressione E para exercitar", + "city.exercise_active": "Exercitando", + "city.exercise_stopHint": "para parar", + "city.exercise_ok": "Boa! +2 de saúde.", + "city.exercise_need_equipment": "Aproxime-se de um equipamento da praça.", + "city.exercise_need_awake": "Você precisa estar acordado para se exercitar.", + "city.exercise_tired": "Você está cansado demais para se exercitar.", + "city.exercise_max_health": "Saúde já está no máximo!", + "city.exerciseStart": "Começando a se exercitar…", + "city.exerciseStop": "Exercício encerrado." } diff --git a/public/js/city/app.js b/public/js/city/app.js index 9749031..377e54d 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -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, diff --git a/public/js/city/audio.js b/public/js/city/audio.js old mode 100644 new mode 100755 diff --git a/resources/views/city/viewer.blade.php b/resources/views/city/viewer.blade.php index 3152f7b..f1367b4 100755 --- a/resources/views/city/viewer.blade.php +++ b/resources/views/city/viewer.blade.php @@ -27,6 +27,13 @@ {{ __('city.error') }} + {{-- Gym equipment proximity prompt --}} + + {{-- Hospital admission banner --}}
{{ __('city.hud_thirst') }}: —
{{ __('city.hud_energy') }}: —
+
{{ __('city.hud_health') }}: —
{{ __('city.hud_money') }}: —
{{ __('city.hud_state') }}: —
{{ __('city.hud_education') }}: —
+
{{ __('city.hud_exercise') }}: —
{{ __('city.hud_inventory') }}: —
{{ __('city.hud_actions') }}
@@ -267,6 +276,12 @@ hospitalTitle: @json(__('city.hospital_title')), hospitalLeft: @json(__('city.hospital_left')), hospitalReleased: @json(__('city.hospital_released')), + exerciseUse: @json(__('city.exercise_use')), + exerciseActive: @json(__('city.exercise_active')), + exerciseStopHint: @json(__('city.exercise_stopHint')), + exerciseStart: @json(__('city.exerciseStart')), + exerciseStop: @json(__('city.exerciseStop')), + exerciseMaxHealth: @json(__('city.exercise_max_health')), }, citySlug: @json($city->slug), }; diff --git a/tests/Feature/HospitalAdmissionTest.php b/tests/Feature/HospitalAdmissionTest.php index 9b5d179..e848e1f 100644 --- a/tests/Feature/HospitalAdmissionTest.php +++ b/tests/Feature/HospitalAdmissionTest.php @@ -11,7 +11,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; /** - * Traffic accident → hospital admission for two game days. + * Traffic accident → hospital admission for one game hour. */ class HospitalAdmissionTest extends TestCase { @@ -34,9 +34,9 @@ class HospitalAdmissionTest extends TestCase } /** - * A vehicle collision admits the character for two game days at the hospital. + * A vehicle collision admits the character for one game hour at the hospital. */ - public function test_accident_admits_character_for_two_game_days(): void + public function test_accident_admits_character_for_one_game_hour(): void { $response = $this->postJson('/city/actions/accident'); $response->assertOk() @@ -51,7 +51,7 @@ class HospitalAdmissionTest extends TestCase $this->character->hospitalized_until->getTimestamp(), 5, ); - $this->assertSame(Character::HOSPITAL_STAY_SECONDS, 480, 'Two game days = 2 × 240s'); + $this->assertSame(Character::HOSPITAL_STAY_SECONDS, 10, 'One game hour = 240s / 24'); $hospital = Building::where('type', Building::TYPE_HOSPITAL)->firstOrFail(); $this->assertSame((float) $hospital->pos_x, (float) $this->character->pos_x); @@ -88,9 +88,9 @@ class HospitalAdmissionTest extends TestCase } /** - * After the two-day stay the ticker releases the character. + * After the one-hour stay the ticker releases the character. */ - public function test_ticker_releases_after_two_game_days(): void + public function test_ticker_releases_after_one_game_hour(): void { $this->character->admitToHospital(); // Move the stay into the past to simulate elapsed time. diff --git a/tests/Feature/PlazaExerciseTest.php b/tests/Feature/PlazaExerciseTest.php new file mode 100644 index 0000000..348addd --- /dev/null +++ b/tests/Feature/PlazaExerciseTest.php @@ -0,0 +1,139 @@ +seed(CityWorldSeeder::class); + $this->user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]); + $this->character = Character::ensureForUser($this->user); + $this->actingAs($this->user); + } + + /** + * City payload includes plazas with gym equipment. + */ + public function test_city_data_includes_plazas(): void + { + $this->getJson('/city/data') + ->assertOk() + ->assertJsonStructure([ + 'plazas' => [ + ['id', 'position', 'size', 'equipment' => [['id', 'kind', 'position']]], + ], + ]); + + $this->assertNotEmpty(CityPlazas::forCity($this->character->city)); + } + + /** + * Exercising near equipment raises health and increments the counter. + */ + public function test_exercise_gains_health_near_equipment(): void + { + $eq = CityPlazas::forCity($this->character->city)[0]['equipment'][0]; + $this->character->forceFill([ + 'pos_x' => $eq['position'][0], + 'pos_z' => $eq['position'][1], + 'health' => 80, + 'energy' => 90, + 'state' => Character::STATE_AWAKE, + ])->save(); + + $this->postJson('/city/actions/exercise', [ + 'pos_x' => $eq['position'][0], + 'pos_z' => $eq['position'][1], + ])->assertOk() + ->assertJsonPath('character.health', 82) + ->assertJsonPath('character.state', Character::STATE_EXERCISING); + + $this->character->refresh(); + $this->assertSame(82, (int) $this->character->health); + $this->assertSame(1, (int) $this->character->exercise_count); + $this->assertSame(88, (int) $this->character->energy); + } + + /** + * Far from equipment the action is rejected. + */ + public function test_exercise_requires_nearby_equipment(): void + { + $eq = CityPlazas::forCity($this->character->city)[0]['equipment'][0]; + $this->character->forceFill([ + 'pos_x' => $eq['position'][0] + 50, + 'pos_z' => $eq['position'][1] + 50, + 'health' => 50, + ])->save(); + + $this->postJson('/city/actions/exercise', [ + 'pos_x' => $eq['position'][0] + 50, + 'pos_z' => $eq['position'][1] + 50, + ])->assertStatus(422); + } + + /** + * Max health refuses further gains but keeps the character healthy. + */ + public function test_exercise_stops_at_max_health(): void + { + $eq = CityPlazas::forCity($this->character->city)[0]['equipment'][0]; + $this->character->forceFill([ + 'pos_x' => $eq['position'][0], + 'pos_z' => $eq['position'][1], + 'health' => 100, + ])->save(); + + $this->postJson('/city/actions/exercise', [ + 'pos_x' => $eq['position'][0], + 'pos_z' => $eq['position'][1], + ])->assertOk() + ->assertJsonPath('character.health', 100); + + $this->assertSame(0, (int) $this->character->fresh()->exercise_count); + } + + /** + * stop-exercise returns the character to awake. + */ + public function test_stop_exercise_returns_awake(): void + { + $eq = CityPlazas::forCity($this->character->city)[0]['equipment'][0]; + $this->character->forceFill([ + 'pos_x' => $eq['position'][0], + 'pos_z' => $eq['position'][1], + 'health' => 70, + 'state' => Character::STATE_AWAKE, + ])->save(); + + $this->postJson('/city/actions/exercise', [ + 'pos_x' => $eq['position'][0], + 'pos_z' => $eq['position'][1], + ])->assertOk(); + + $this->postJson('/city/actions/stop-exercise') + ->assertOk() + ->assertJsonPath('character.state', Character::STATE_AWAKE); + } +}