Files
City10/app/Http/Controllers/Web/CityViewerController.php
T
marcocosta10000 b8c31e7be4 feat(city): add east shore beach leisure activity
Characters can now relax on the beach sand to recover energy and
health. The server clamps position to the shoreline, exposes beach
geometry in the city payload, and the client renders animated waves,
sand, stalls, chairs, and ambient NPCs.

Includes Beach support class, new beach action endpoint, localization
strings, and feature tests.
2026-10-31 20:35:22 -03:00

231 lines
8.1 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\AiAgent;
use App\Models\Building;
use App\Models\Character;
use App\Models\City;
use App\Models\Job;
use App\Models\Store;
use App\Support\Beach;
use App\Support\CityPlazas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* City viewer entry point and JSON payload.
*
* Renders the 3D scene for a chosen city and exposes characters, buildings
* and jobs so the frontend can simulate a living persistent world.
*/
class CityViewerController extends Controller
{
/**
* Render the 3D city viewer page.
*/
public function __invoke(Request $request): View
{
$city = $this->resolveCity($request);
return view('city.viewer', [
'city' => $city,
]);
}
/**
* JSON payload driving the 3D scene for the selected city.
*/
public function data(Request $request): JsonResponse
{
$city = $this->resolveCity($request);
$user = Auth::user();
$me = $user ? Character::ensureForUser($user, $city) : null;
if ($me && (int) $me->city_id !== (int) $city->id) {
// Keep the player's home city authoritative when no explicit ?city= given.
if (! $request->filled('city')) {
$city = $me->city;
}
}
$stores = Store::query()
->orderBy('id')
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open', 'owner_id'])
->map(fn (Store $s): array => [
'id' => $s->id,
'name' => $s->name,
'type' => $s->type,
'position' => [(float) $s->pos_x, (float) $s->pos_y, (float) $s->pos_z],
'size' => [(float) $s->width, (float) $s->height, (float) $s->depth],
'is_open' => (bool) $s->is_open,
'owner_id' => $s->owner_id,
]);
$characters = Character::query()
->where('city_id', $city->id)
->with('user:id,name')
->get()
->map(fn (Character $c): array => [
'id' => $c->id,
'name' => $c->name,
'type' => $c->user_id === null ? 'child' : 'citizen',
'status' => $c->state,
'position' => [(float) $c->pos_x, (float) $c->pos_y, (float) $c->pos_z],
'is_online' => (bool) $c->is_online,
'is_me' => $me !== null && (int) $me->id === (int) $c->id,
'owner_name' => $c->user?->name,
]);
$buildings = Building::query()
->where('city_id', $city->id)
->get()
->map(fn (Building $b): array => [
'id' => $b->id,
'type' => $b->type,
'name' => $b->name,
'position' => [(float) $b->pos_x, 0, (float) $b->pos_z],
'size' => [(float) $b->width, (float) $b->height, (float) $b->depth],
'is_complete' => (bool) $b->is_complete,
'build_progress' => (int) $b->build_progress,
'owner_character_id' => $b->owner_character_id,
]);
$jobs = Job::query()
->where('city_id', $city->id)
->get()
->map(fn (Job $j): array => [
'id' => $j->id,
'title' => $j->title,
'salary' => (float) $j->salary,
'requires_education' => (int) $j->requires_education,
'store_id' => $j->store_id,
]);
$payload = [
'generated_at' => now()->toIso8601String(),
'city' => [
'id' => $city->id,
'name' => $city->name,
'slug' => $city->slug,
'bounds' => [
'min_x' => (float) $city->bounds_min_x,
'max_x' => (float) $city->bounds_max_x,
'min_z' => (float) $city->bounds_min_z,
'max_z' => (float) $city->bounds_max_z,
],
],
'stores' => $stores,
'characters' => $characters,
'buildings' => $buildings,
'jobs' => $jobs,
'me' => $me ? [
'id' => $me->id,
'name' => $me->name,
'state' => $me->state,
'money' => (float) $me->money,
'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,
'position' => [(float) $me->pos_x, (float) $me->pos_y, (float) $me->pos_z],
'hospitalized_until' => $me->hospitalized_until?->toIso8601String(),
'inventory' => $me->inventory()->get()->mapWithKeys(fn ($i) => [$i->item => (int) $i->qty])->all(),
] : null,
'layout' => $this->cityLayout($city, $stores->all()),
'plazas' => CityPlazas::forCity($city),
'beach' => Beach::geometry($city),
'agents' => AiAgent::orderBy('id')->get()->map(fn ($a): array => [
'id' => $a->id,
'name' => $a->name,
'type' => $a->type,
'status' => $a->status,
'position' => [(float) $a->pos_x, (float) $a->pos_y, (float) $a->pos_z],
'rotation_y' => (float) $a->rotation_y,
'current_goal' => $a->current_goal,
'last_decision' => $a->last_decision,
]),
];
return response()->json($payload)->header('Cache-Control', 'no-store');
}
/**
* Resolve the city from the query string or the active default.
*/
private function resolveCity(Request $request): City
{
$slug = $request->query('city');
if (is_string($slug) && $slug !== '') {
$city = City::query()
->where('slug', $slug)
->orWhere('id', ctype_digit($slug) ? (int) $slug : 0)
->first();
if ($city) {
return $city;
}
}
$user = Auth::user();
if ($user) {
$character = Character::query()->where('user_id', $user->id)->first();
if ($character?->city) {
return $character->city;
}
}
return City::active() ?? City::query()->firstOrFail();
}
/**
* Build the street grid layout from city bounds and store anchors.
*
* @param array<int, array<string, mixed>> $storeRows
* @return array<string, mixed>
*/
private function cityLayout(City $city, array $storeRows): 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;
$roads = [];
for ($x = $minX; $x <= $maxX; $x += 40) {
$roads[] = ['from' => [$x, 0, $minZ], 'to' => [$x, 0, $maxZ], 'width' => 8, 'axis' => 'z'];
}
for ($z = $minZ; $z <= $maxZ; $z += 40) {
$roads[] = ['from' => [$minX, 0, $z], 'to' => [$maxX, 0, $z], 'width' => 8, 'axis' => 'x'];
}
$blocks = [];
for ($x = $minX; $x < $maxX; $x += 40) {
for ($z = $minZ; $z < $maxZ; $z += 40) {
$blocks[] = ['min' => [$x + 5, 0, $z + 5], 'max' => [$x + 35, 0, $z + 35]];
}
}
return [
'roads' => $roads,
'blocks' => $blocks,
'origin' => [0, 0, 0],
'bounds' => [
'min_x' => $minX,
'max_x' => $maxX,
'min_z' => $minZ,
'max_z' => $maxZ,
],
];
}
}