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:
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Building;
|
||||
use App\Models\City;
|
||||
use App\Models\Store;
|
||||
|
||||
/**
|
||||
* Deterministic public plazas with outdoor gym equipment.
|
||||
*
|
||||
* Plazas replace free city blocks (not near stores, houses or the hospital).
|
||||
* Equipment world positions must match the frontend generator so the server
|
||||
* can validate exercise proximity.
|
||||
*/
|
||||
class CityPlazas
|
||||
{
|
||||
/**
|
||||
* Equipment kinds rendered on each plaza.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const EQUIPMENT_KINDS = ['bench', 'bars', 'bike', 'treadmill'];
|
||||
|
||||
/**
|
||||
* Build plaza + equipment layout for a city.
|
||||
*
|
||||
* @return list<array{id: string, position: array{0: float, 1: float}, size: array{0: float, 1: float}, equipment: list<array{id: string, kind: string, position: array{0: float, 1: float}}>}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Regular → Executable
+1
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Adds fitness tracking to characters.
|
||||
*
|
||||
* health is 0-100 and rises while the character exercises on public plaza
|
||||
* equipment; exercise_count is a lifetime counter for progress display.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('characters', function (Blueprint $table): void {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -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."
|
||||
}
|
||||
|
||||
+14
-2
@@ -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."
|
||||
}
|
||||
|
||||
+14
-2
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Regular → Executable
@@ -27,6 +27,13 @@
|
||||
{{ __('city.error') }}
|
||||
</div>
|
||||
|
||||
{{-- Gym equipment proximity prompt --}}
|
||||
<div id="exercise-prompt"
|
||||
style="display:none; position:absolute; bottom:1.4rem; left:50%; transform:translateX(-50%);
|
||||
z-index:20; background:rgba(20,83,45,.92); color:#bbf7d0; border:1px solid #4ade80;
|
||||
border-radius:.6rem; padding:.5rem 1rem; font-size:.95rem; font-weight:600; white-space:nowrap;">
|
||||
</div>
|
||||
|
||||
{{-- Hospital admission banner --}}
|
||||
<div id="hospital-banner"
|
||||
style="display:none; position:absolute; top:5.2rem; left:50%; transform:translateX(-50%);
|
||||
@@ -44,9 +51,11 @@
|
||||
<div>{{ __('city.hud_hunger') }}: <span id="hud-hunger" style="color:#fca5a5;">—</span></div>
|
||||
<div>{{ __('city.hud_thirst') }}: <span id="hud-thirst" style="color:#7dd3fc;">—</span></div>
|
||||
<div>{{ __('city.hud_energy') }}: <span id="hud-energy" style="color:#86efac;">—</span></div>
|
||||
<div>{{ __('city.hud_health') }}: <span id="hud-health" style="color:#f9a8d4;">—</span></div>
|
||||
<div>{{ __('city.hud_money') }}: <span id="hud-money" style="color:#fde68a;">—</span></div>
|
||||
<div>{{ __('city.hud_state') }}: <span id="hud-char-state" style="color:#c4b5fd;">—</span></div>
|
||||
<div>{{ __('city.hud_education') }}: <span id="hud-education">—</span></div>
|
||||
<div>{{ __('city.hud_exercise') }}: <span id="hud-exercise-count" style="color:#c4b5fd;">—</span></div>
|
||||
<div style="margin-top:.35rem; color:#cbd5e1;">{{ __('city.hud_inventory') }}: <span id="hud-inv">—</span></div>
|
||||
|
||||
<div style="margin-top:.6rem; color:#fff; font-weight:600;">{{ __('city.hud_actions') }}</div>
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Character;
|
||||
use App\Models\User;
|
||||
use App\Support\CityPlazas;
|
||||
use Database\Seeders\CityWorldSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Public plaza gym equipment and health gains from exercise.
|
||||
*/
|
||||
class PlazaExerciseTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
|
||||
protected Character $character;
|
||||
|
||||
/**
|
||||
* Boot world fixtures.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user