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.
231 lines
8.1 KiB
PHP
Executable File
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,
|
|
],
|
|
];
|
|
}
|
|
}
|