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.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
@@ -9,6 +9,7 @@ use App\Models\City;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Job;
|
||||
use App\Models\Transaction;
|
||||
use App\Support\Beach;
|
||||
use App\Support\CityPlazas;
|
||||
use App\Support\MissionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -48,7 +49,10 @@ class CityActionController extends Controller
|
||||
}
|
||||
|
||||
$city = $character->city;
|
||||
$x = max((float) $city->bounds_min_x, min((float) $city->bounds_max_x, (float) $data['pos_x']));
|
||||
// Players may persist onto the east beach sand (past the road grid)
|
||||
// but never beyond the shoreline.
|
||||
$maxX = Beach::sandTo($city) + 2;
|
||||
$x = max((float) $city->bounds_min_x, min($maxX, (float) $data['pos_x']));
|
||||
$z = max((float) $city->bounds_min_z, min((float) $city->bounds_max_z, (float) $data['pos_z']));
|
||||
|
||||
$character->forceFill([
|
||||
@@ -100,12 +104,63 @@ class CityActionController extends Controller
|
||||
'build' => $this->build($request, $character),
|
||||
'apply-job' => $this->applyJob($request, $character),
|
||||
'exercise' => $this->exercise($request, $character),
|
||||
'beach' => $this->beach($request, $character),
|
||||
'stop-exercise' => $this->stopExercise($character),
|
||||
'ride' => $this->ride($request, $character),
|
||||
default => response()->json(['message' => __('city.action_unknown')], 422),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Relax on the east beach while standing on the sand.
|
||||
*
|
||||
* The client posts this on a slow timer during a relax session (the
|
||||
* same pattern as exercise): each tick recovers a bit of energy and
|
||||
* health, capped at 100, and only inside the beach zone.
|
||||
*/
|
||||
private function beach(Request $request, Character $character): JsonResponse
|
||||
{
|
||||
if ($character->state === Character::STATE_SLEEPING) {
|
||||
return response()->json(['message' => __('city.exercise_need_awake')], 422);
|
||||
}
|
||||
|
||||
if ($character->state === Character::STATE_EXERCISING) {
|
||||
return response()->json(['message' => __('city.beach_busy')], 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;
|
||||
|
||||
if (! Beach::isOnBeach($character->city, $x, $z)) {
|
||||
return response()->json(['message' => __('city.beach_need')], 422);
|
||||
}
|
||||
|
||||
if ((int) $character->energy >= 100 && (int) $character->health >= 100) {
|
||||
return response()->json([
|
||||
'message' => __('city.beach_full'),
|
||||
'full' => true,
|
||||
'character' => $this->characterData($character),
|
||||
]);
|
||||
}
|
||||
|
||||
$character->forceFill([
|
||||
'health' => min(100, (int) $character->health + 1),
|
||||
'energy' => min(100, (int) $character->energy + 1),
|
||||
'pos_x' => $x,
|
||||
'pos_z' => $z,
|
||||
])->save();
|
||||
|
||||
return response()->json([
|
||||
'message' => __('city.beach_ok'),
|
||||
'character' => $this->characterData($character),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Board a nearby bus stop or train for a short ride.
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -141,6 +142,7 @@ class CityViewerController extends Controller
|
||||
] : 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,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\City;
|
||||
|
||||
/**
|
||||
* East-shore beach geometry shared by the world payload, the leisure
|
||||
* action validation and the position clamp.
|
||||
*
|
||||
* Every value derives from the city bounds so the sand always starts just
|
||||
* past the eastern road grid, whatever the city size is.
|
||||
*/
|
||||
class Beach
|
||||
{
|
||||
/** Gap between the last grid road and the sand. */
|
||||
public const APPROACH = 4.0;
|
||||
|
||||
/** Sand depth from the approach line to the shoreline. */
|
||||
public const SAND_DEPTH = 60.0;
|
||||
|
||||
/** Sea depth beyond the shoreline. */
|
||||
public const SEA_DEPTH = 90.0;
|
||||
|
||||
/** Half of the beach span along Z (covers the whole layout). */
|
||||
public const Z_HALF = 160.0;
|
||||
|
||||
/**
|
||||
* Eastern edge of the road grid (same derivation the layout uses).
|
||||
*/
|
||||
public static function layoutMaxX(City $city): float
|
||||
{
|
||||
return (float) (ceil(((float) $city->bounds_max_x) / 40) * 40 + 40);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the sand begins (east of the grid).
|
||||
*/
|
||||
public static function sandFrom(City $city): float
|
||||
{
|
||||
return self::layoutMaxX($city) + self::APPROACH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shoreline (sand meets sea).
|
||||
*/
|
||||
public static function sandTo(City $city): float
|
||||
{
|
||||
return self::sandFrom($city) + self::SAND_DEPTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Far edge of the sea.
|
||||
*/
|
||||
public static function seaTo(City $city): float
|
||||
{
|
||||
return self::sandTo($city) + self::SEA_DEPTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometry descriptor sent to the world payload (single source of
|
||||
* truth for the client-side beach build).
|
||||
*
|
||||
* @return array{sand_from: float, sand_to: float, sea_to: float, z_min: float, z_max: float}
|
||||
*/
|
||||
public static function geometry(City $city): array
|
||||
{
|
||||
return [
|
||||
'sand_from' => self::sandFrom($city),
|
||||
'sand_to' => self::sandTo($city),
|
||||
'sea_to' => self::seaTo($city),
|
||||
'z_min' => -self::Z_HALF,
|
||||
'z_max' => self::Z_HALF,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a point lies on the leisure beach (sand up to the shoreline).
|
||||
*/
|
||||
public static function isOnBeach(City $city, float $x, float $z): bool
|
||||
{
|
||||
return $x >= self::sandFrom($city) - 1
|
||||
&& $x <= self::sandTo($city) + 2
|
||||
&& $z >= -self::Z_HALF
|
||||
&& $z <= self::Z_HALF;
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,13 @@
|
||||
"city.exercise_active": "Exercising",
|
||||
"city.exercise_stopHint": "to stop",
|
||||
"city.exercise_ok": "Nice! +2 health.",
|
||||
"city.beach_need": "To relax, head to the beach.",
|
||||
"city.beach_ok": "You relaxed at the beach and recovered energy!",
|
||||
"city.beach_full": "Energy and health are already at max.",
|
||||
"city.beach_busy": "Finish your current exercise first.",
|
||||
"city.beach_start": "Resting on the sand…",
|
||||
"city.beach_prompt": "Relax at the beach",
|
||||
"city.beach_active": "Relaxing at the beach",
|
||||
"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.",
|
||||
|
||||
@@ -212,6 +212,13 @@
|
||||
"city.exercise_active": "Ejercitándose",
|
||||
"city.exercise_stopHint": "para parar",
|
||||
"city.exercise_ok": "¡Bien! +2 de salud.",
|
||||
"city.beach_need": "Para relajar, ve a la playa.",
|
||||
"city.beach_ok": "¡Relajaste en la playa y recuperaste energía!",
|
||||
"city.beach_full": "La energía y la salud ya están al máximo.",
|
||||
"city.beach_busy": "Termina el ejercicio actual primero.",
|
||||
"city.beach_start": "Descansando en la arena…",
|
||||
"city.beach_prompt": "Relajar en la playa",
|
||||
"city.beach_active": "Relajándote en la playa",
|
||||
"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.",
|
||||
|
||||
@@ -212,6 +212,13 @@
|
||||
"city.exercise_active": "Exercitando",
|
||||
"city.exercise_stopHint": "para parar",
|
||||
"city.exercise_ok": "Boa! +2 de saúde.",
|
||||
"city.beach_need": "Para relaxar, vá até a praia.",
|
||||
"city.beach_ok": "Você relaxou na praia e recuperou ânimo!",
|
||||
"city.beach_full": "Energia e saúde já estão no máximo.",
|
||||
"city.beach_busy": "Termine o exercício atual primeiro.",
|
||||
"city.beach_start": "Descansando na areia…",
|
||||
"city.beach_prompt": "Relaxar na praia",
|
||||
"city.beach_active": "Relaxando na praia",
|
||||
"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.",
|
||||
|
||||
+537
-2
@@ -237,6 +237,10 @@ let exercise = {
|
||||
animPhase: 0,
|
||||
nearId: null,
|
||||
};
|
||||
// Beach leisure (east shore): geometry from the payload + relax session.
|
||||
let relax = { active: false, timer: 0, animPhase: 0 };
|
||||
let beach = null;
|
||||
let beachSystem = null;
|
||||
let remoteCitizens = new Map(); // characterId -> mesh
|
||||
let presenceTimer = null;
|
||||
let vehicleHeadlights = [];
|
||||
@@ -1801,6 +1805,11 @@ function buildHumanoid(agent, showLabel = true) {
|
||||
mayor: { skin: 0xe0ac69, hair: 0x555555, shirt: 0x1a1a2e, pants: 0x111111, scale: 1.05 },
|
||||
traffic_officer: { skin: 0xd2956b, hair: 0x1a1a1a, shirt: 0x2a4a2a, pants: 0x1a1a1a, scale: 1 },
|
||||
crowd: { skin: 0xf1c27d, hair: 0x2a1a0a, shirt: [0x8a4a4a, 0x4a6a8a, 0x5a7a4a, 0x7a5a8a, 0x8a7a4a][Math.floor(Math.random() * 5)], pants: 0x333333, scale: 1 },
|
||||
// Beach crowd: swimwear palette; lifeguards wear classic red/yellow.
|
||||
bather: { skin: 0xf1c27d, hair: [0x2a1a0a, 0x553311, 0x111111, 0x886644][Math.floor(Math.random() * 4)], shirt: [0xd9534f, 0x5bc0de, 0xf0ad4e, 0x8e44ad, 0x2ecc71][Math.floor(Math.random() * 5)], pants: [0x2c5f8a, 0xc0392b, 0x111111][Math.floor(Math.random() * 3)], scale: 1 },
|
||||
lifeguard: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xd92b2b, pants: 0xf2e14c, scale: 1 },
|
||||
surfer: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x3fb6c9, pants: 0x1c1c1c, scale: 1 },
|
||||
basket: { skin: 0xd2956b, hair: 0x111111, shirt: 0xe8802a, pants: 0xf2f2f2, scale: 1 },
|
||||
};
|
||||
const cfgc = configs[type] || configs.pedestrian;
|
||||
const s = cfgc.scale;
|
||||
@@ -2760,6 +2769,424 @@ function initPlazas(list) {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Beach (east shore): sand, animated sea, food stalls, chairs, court,
|
||||
// lifeguards, bathers, surfers and basket players.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Shared wave height — vertex animation and swimmers/surfers use it. */
|
||||
function beachWaveY(x, z, t) {
|
||||
return Math.sin(x * 0.16 + t * 1.5) * 0.3 + Math.sin(z * 0.11 + t * 0.9) * 0.2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the whole east-beach scene from the payload geometry.
|
||||
*
|
||||
* @param {object} cfg beach geometry (sand_from/sand_to/sea_to/z_min/z_max)
|
||||
* @returns {{update: Function, counts: Function}}
|
||||
*/
|
||||
function initBeach(cfg) {
|
||||
const sandFrom = cfg.sand_from;
|
||||
const sandTo = cfg.sand_to;
|
||||
const seaTo = cfg.sea_to;
|
||||
const zMin = cfg.z_min;
|
||||
const zMax = cfg.z_max;
|
||||
const midZ = (zMin + zMax) / 2;
|
||||
const zDepth = zMax - zMin;
|
||||
|
||||
const group = new THREE.Group();
|
||||
const counts = {
|
||||
bathers: 0, swimmers: 0, surfers: 0, basket: 0,
|
||||
lifeguards: 0, chairs: 0, umbrellas: 0, stalls: 0, towers: 0,
|
||||
};
|
||||
|
||||
// ---- Sand + wet shoreline strip ----
|
||||
const sandMat = new THREE.MeshStandardMaterial({ color: 0xe7d6a3, roughness: 1 });
|
||||
const sand = new THREE.Mesh(new THREE.PlaneGeometry(sandTo - sandFrom, zDepth), sandMat);
|
||||
sand.rotation.x = -Math.PI / 2;
|
||||
sand.position.set((sandFrom + sandTo) / 2, 0.05, midZ);
|
||||
sand.receiveShadow = true;
|
||||
group.add(sand);
|
||||
|
||||
const wetMat = new THREE.MeshStandardMaterial({ color: 0xcbb982, roughness: 0.85, metalness: 0.08 });
|
||||
const wet = new THREE.Mesh(new THREE.PlaneGeometry(6, zDepth), wetMat);
|
||||
wet.rotation.x = -Math.PI / 2;
|
||||
wet.position.set(sandTo - 3, 0.055, midZ);
|
||||
group.add(wet);
|
||||
|
||||
// ---- Sea with animated waves (one mesh, vertex displacement) ----
|
||||
const seaW = seaTo - sandTo;
|
||||
const seaGeo = new THREE.PlaneGeometry(seaW, zDepth, 40, 56);
|
||||
const seaMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x2f7fb5, roughness: 0.35, metalness: 0.15,
|
||||
transparent: true, opacity: 0.94,
|
||||
});
|
||||
const seaMesh = new THREE.Mesh(seaGeo, seaMat);
|
||||
seaMesh.rotation.x = -Math.PI / 2;
|
||||
seaMesh.position.set(sandTo + seaW / 2, 0.09, midZ);
|
||||
seaMesh.frustumCulled = false;
|
||||
group.add(seaMesh);
|
||||
const seaBase = Float32Array.from(seaGeo.attributes.position.array);
|
||||
const seaWorldX = sandTo + seaW / 2;
|
||||
|
||||
// Foam line where the waves break (opacity pulses with the swell).
|
||||
const foamMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xeef6fa, roughness: 0.9, transparent: true, opacity: 0.7,
|
||||
});
|
||||
const foam = new THREE.Mesh(new THREE.PlaneGeometry(2.6, zDepth), foamMat);
|
||||
foam.rotation.x = -Math.PI / 2;
|
||||
foam.position.set(sandTo + 1.2, 0.1, midZ);
|
||||
group.add(foam);
|
||||
|
||||
// ---- Basketball court: painted slab + two hoops ----
|
||||
const courtTex = canvasTexture(512, (ctx, s) => {
|
||||
ctx.fillStyle = '#c9a06a';
|
||||
ctx.fillRect(0, 0, s, s);
|
||||
ctx.strokeStyle = '#f4efe3';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeRect(26, 26, s - 52, s - 52);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(26, s / 2);
|
||||
ctx.lineTo(s - 26, s / 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s / 2, 58, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
// Keys at both ends.
|
||||
const keyW = 110, keyH = 150;
|
||||
ctx.strokeRect(s / 2 - keyW / 2, 26, keyW, keyH);
|
||||
ctx.strokeRect(s / 2 - keyW / 2, s - 26 - keyH, keyW, keyH);
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, 26 + keyH, 58, 0, Math.PI);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(s / 2, s - 26 - keyH, 58, Math.PI, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}, 1, 1);
|
||||
|
||||
const courtX = sandFrom + 24;
|
||||
const courtZ = -95;
|
||||
const court = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(16, 26),
|
||||
new THREE.MeshStandardMaterial({ map: courtTex, roughness: 0.95 })
|
||||
);
|
||||
court.rotation.x = -Math.PI / 2;
|
||||
court.position.set(courtX, 0.065, courtZ);
|
||||
court.receiveShadow = true;
|
||||
group.add(court);
|
||||
|
||||
const poleMatBeach = new THREE.MeshStandardMaterial({ color: 0x5a5f66, roughness: 0.6, metalness: 0.4 });
|
||||
const boardMat = new THREE.MeshStandardMaterial({ color: 0xf2e9d8, roughness: 0.7 });
|
||||
const ringMat = new THREE.MeshStandardMaterial({ color: 0xe86a17, roughness: 0.5, metalness: 0.3 });
|
||||
const addHoop = (zEnd, dir) => {
|
||||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.12, 3.2, 8), poleMatBeach);
|
||||
pole.position.set(courtX, 1.6, zEnd);
|
||||
pole.castShadow = true;
|
||||
group.add(pole);
|
||||
|
||||
const board = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.05, 0.1), boardMat);
|
||||
board.position.set(courtX, 3.05, zEnd - dir * 0.3);
|
||||
board.castShadow = true;
|
||||
group.add(board);
|
||||
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.24, 0.035, 8, 20), ringMat);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.position.set(courtX, 2.9, zEnd - dir * 0.75);
|
||||
group.add(ring);
|
||||
};
|
||||
addHoop(courtZ - 13.5, 1);
|
||||
addHoop(courtZ + 13.5, -1);
|
||||
|
||||
// ---- Food stalls (striped awnings) ----
|
||||
const awningTex = canvasTexture(128, (ctx, s) => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
ctx.fillStyle = i % 2 ? '#d94b3a' : '#f7f2e6';
|
||||
ctx.fillRect((i * s) / 8, 0, s / 8, s);
|
||||
}
|
||||
}, 2, 1);
|
||||
const woodMat = new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 0.9 });
|
||||
const woodTopMat = new THREE.MeshStandardMaterial({ color: 0x6e4526, roughness: 0.85 });
|
||||
const awningMat = new THREE.MeshStandardMaterial({ map: awningTex, roughness: 0.85, side: THREE.DoubleSide });
|
||||
const postMat = new THREE.MeshStandardMaterial({ color: 0xd8c9a8, roughness: 0.9 });
|
||||
|
||||
const addStall = (z) => {
|
||||
const x = sandFrom + 5;
|
||||
const counter = new THREE.Mesh(new THREE.BoxGeometry(3.6, 1.05, 2.2), woodMat);
|
||||
counter.position.set(x, 0.53, z);
|
||||
counter.castShadow = true;
|
||||
counter.receiveShadow = true;
|
||||
group.add(counter);
|
||||
|
||||
const top = new THREE.Mesh(new THREE.BoxGeometry(3.9, 0.08, 2.5), woodTopMat);
|
||||
top.position.set(x, 1.1, z);
|
||||
group.add(top);
|
||||
|
||||
for (const [ox, oz] of [[-1.6, -1], [1.6, -1], [-1.6, 1], [1.6, 1]]) {
|
||||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.3, 6), postMat);
|
||||
post.position.set(x + ox, 1.2, z + oz);
|
||||
post.castShadow = true;
|
||||
group.add(post);
|
||||
}
|
||||
|
||||
const awning = new THREE.Mesh(new THREE.BoxGeometry(4.3, 0.1, 3.1), awningMat);
|
||||
awning.position.set(x, 2.4, z);
|
||||
awning.rotation.z = 0.06;
|
||||
awning.castShadow = true;
|
||||
group.add(awning);
|
||||
|
||||
// Crates of fruit on the counter.
|
||||
const crate1 = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.28, 0.5),
|
||||
new THREE.MeshStandardMaterial({ color: 0xd93b3b, roughness: 0.8 }));
|
||||
crate1.position.set(x - 0.8, 1.28, z + 0.3);
|
||||
group.add(crate1);
|
||||
const crate2 = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.28, 0.5),
|
||||
new THREE.MeshStandardMaterial({ color: 0xe8b21e, roughness: 0.8 }));
|
||||
crate2.position.set(x + 0.7, 1.28, z - 0.2);
|
||||
group.add(crate2);
|
||||
|
||||
counts.stalls++;
|
||||
};
|
||||
addStall(40);
|
||||
addStall(78);
|
||||
|
||||
// ---- Beach chairs + umbrellas facing the sea ----
|
||||
const chairMat = new THREE.MeshStandardMaterial({ color: 0xf3efe4, roughness: 0.9 });
|
||||
const chairBackMat = new THREE.MeshStandardMaterial({ color: 0x4a90c2, roughness: 0.9 });
|
||||
const addChair = (x, z) => {
|
||||
const chair = new THREE.Group();
|
||||
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.08, 1.7), chairMat);
|
||||
seat.position.y = 0.32;
|
||||
chair.add(seat);
|
||||
const back = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.62, 0.07), chairBackMat);
|
||||
back.position.set(0, 0.58, -0.85);
|
||||
back.rotation.x = -0.35;
|
||||
chair.add(back);
|
||||
for (const [ox, oz] of [[-0.3, -0.7], [0.3, -0.7], [-0.3, 0.7], [0.3, 0.7]]) {
|
||||
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.3, 0.06), chairMat);
|
||||
leg.position.set(ox, 0.15, oz);
|
||||
chair.add(leg);
|
||||
}
|
||||
chair.position.set(x, 0.05, z);
|
||||
chair.rotation.y = Math.PI / 2; // face the sea (+X)
|
||||
chair.traverse(o => { if (o.isMesh) o.castShadow = true; });
|
||||
group.add(chair);
|
||||
counts.chairs++;
|
||||
};
|
||||
|
||||
const umbrellaPoleMat = new THREE.MeshStandardMaterial({ color: 0xe8e2d2, roughness: 0.8 });
|
||||
const addUmbrella = (x, z) => {
|
||||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 2.5, 6), umbrellaPoleMat);
|
||||
pole.position.set(x, 1.25, z);
|
||||
pole.castShadow = true;
|
||||
group.add(pole);
|
||||
const canopy = new THREE.Mesh(new THREE.ConeGeometry(1.6, 0.6, 10), awningMat);
|
||||
canopy.position.set(x, 2.7, z);
|
||||
canopy.castShadow = true;
|
||||
group.add(canopy);
|
||||
counts.umbrellas++;
|
||||
};
|
||||
|
||||
const chairRowA = sandFrom + 34;
|
||||
const chairRowB = sandFrom + 41;
|
||||
[-30, -14, 2, 18].forEach(z => addChair(chairRowA, z));
|
||||
[-22, 6, 34].forEach(z => addChair(chairRowB, z));
|
||||
[-22, 10].forEach(z => addUmbrella(sandFrom + 37.5, z));
|
||||
|
||||
// ---- Lifeguard tower + guards ----
|
||||
const towerX = sandTo - 7;
|
||||
const towerZ = 16;
|
||||
for (const [ox, oz] of [[-0.6, -0.6], [0.6, -0.6], [-0.6, 0.6], [0.6, 0.6]]) {
|
||||
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.08, 2.5, 6), poleMatBeach);
|
||||
leg.position.set(towerX + ox, 1.25, towerZ + oz);
|
||||
leg.castShadow = true;
|
||||
group.add(leg);
|
||||
}
|
||||
const platform = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.12, 1.7), woodMat);
|
||||
platform.position.set(towerX, 2.56, towerZ);
|
||||
platform.castShadow = true;
|
||||
group.add(platform);
|
||||
const railMat = new THREE.MeshStandardMaterial({ color: 0xd94f3a, roughness: 0.8 });
|
||||
for (const [w, d, ox, oz] of [[1.7, 0.06, 0, -0.82], [0.06, 1.7, -0.82, 0], [0.06, 1.7, 0.82, 0]]) {
|
||||
const rail = new THREE.Mesh(new THREE.BoxGeometry(w, 0.5, d), railMat);
|
||||
rail.position.set(towerX + ox, 2.95, towerZ + oz);
|
||||
group.add(rail);
|
||||
}
|
||||
const roof = new THREE.Mesh(new THREE.BoxGeometry(2, 0.1, 2), awningMat);
|
||||
roof.position.set(towerX, 4.05, towerZ);
|
||||
roof.castShadow = true;
|
||||
group.add(roof);
|
||||
for (const [ox, oz] of [[-0.8, -0.8], [0.8, -0.8], [-0.8, 0.8], [0.8, 0.8]]) {
|
||||
const p = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.35, 6), postMat);
|
||||
p.position.set(towerX + ox, 3.4, towerZ + oz);
|
||||
group.add(p);
|
||||
}
|
||||
counts.towers = 1;
|
||||
|
||||
// ---- Crowd: bathers, swimmers, surfers, basket players, lifeguards ----
|
||||
const bathers = [];
|
||||
[-124, -70, -20, 34, 92, 132].forEach((z, i) => {
|
||||
const b = buildHumanoid({ type: 'bather', name: '' }, false);
|
||||
const x = sandTo - (i % 3) * 1.4;
|
||||
b.position.set(x, -0.28, z);
|
||||
b.rotation.y = i % 2 === 0 ? Math.PI / 2 : Math.PI; // face sea / along shore
|
||||
group.add(b);
|
||||
bathers.push({ mesh: b, baseX: x, baseZ: z, phase: i * 1.7 });
|
||||
counts.bathers++;
|
||||
});
|
||||
|
||||
const swimmers = [];
|
||||
[-58, 52].forEach((z, i) => {
|
||||
const s = buildHumanoid({ type: 'bather', name: '' }, false);
|
||||
s.position.set(sandTo + 12 + i * 6, -0.55, z);
|
||||
s.rotation.y = Math.PI / 2;
|
||||
group.add(s);
|
||||
swimmers.push({ mesh: s, baseX: sandTo + 12 + i * 6, baseZ: z, phase: i * 2.4 });
|
||||
counts.swimmers++;
|
||||
});
|
||||
|
||||
const surfers = [];
|
||||
[{ z: -44, phase: 0 }, { z: 74, phase: Math.PI }].forEach(sCfg => {
|
||||
const rig = new THREE.Group();
|
||||
const board = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.1, 2.3),
|
||||
new THREE.MeshStandardMaterial({ color: 0xf5f2ea, roughness: 0.6 }));
|
||||
board.position.y = 0.06;
|
||||
rig.add(board);
|
||||
const rider = buildHumanoid({ type: 'surfer', name: '' }, false);
|
||||
rider.position.y = 0.1;
|
||||
rig.add(rider);
|
||||
rig.position.set(sandTo + 30, 0.2, sCfg.z);
|
||||
group.add(rig);
|
||||
surfers.push({ mesh: rig, baseX: sandTo + 30, baseZ: sCfg.z, phase: sCfg.phase, span: 26 });
|
||||
counts.surfers++;
|
||||
});
|
||||
|
||||
const basket = [];
|
||||
const lanes = [courtX - 2.4, courtX, courtX + 2.4];
|
||||
lanes.forEach((laneX, i) => {
|
||||
const p = buildHumanoid({ type: 'basket', name: '' }, false);
|
||||
const startZ = courtZ - 8 + i * 8;
|
||||
p.position.set(laneX, 0.05, startZ);
|
||||
group.add(p);
|
||||
basket.push({
|
||||
mesh: p, laneX,
|
||||
z: startZ, dir: i % 2 ? 1 : -1,
|
||||
speed: 3.4 + i * 0.5,
|
||||
endA: courtZ - 11.5, endB: courtZ + 11.5,
|
||||
});
|
||||
counts.basket++;
|
||||
});
|
||||
const ball = new THREE.Mesh(new THREE.SphereGeometry(0.16, 12, 12),
|
||||
new THREE.MeshStandardMaterial({ color: 0xe07b20, roughness: 0.75 }));
|
||||
ball.castShadow = true;
|
||||
group.add(ball);
|
||||
const shot = { timer: 4, phase: 'follow', t: 0, from: new THREE.Vector3(), to: new THREE.Vector3(), target: 0 };
|
||||
|
||||
const towerGuard = buildHumanoid({ type: 'lifeguard', name: '' }, false);
|
||||
towerGuard.position.set(towerX, 2.62, towerZ);
|
||||
group.add(towerGuard);
|
||||
counts.lifeguards++;
|
||||
|
||||
const patrolGuard = buildHumanoid({ type: 'lifeguard', name: '' }, false);
|
||||
patrolGuard.position.set(sandTo - 9, 0.05, -60);
|
||||
group.add(patrolGuard);
|
||||
counts.lifeguards++;
|
||||
|
||||
worldGroup.add(group);
|
||||
|
||||
return {
|
||||
counts: () => counts,
|
||||
update(dt, time) {
|
||||
// Waves: displace the sea plane along its normal (local Z).
|
||||
const pos = seaGeo.attributes.position;
|
||||
const arr = pos.array;
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const lx = seaBase[i * 3];
|
||||
const ly = seaBase[i * 3 + 1];
|
||||
arr[i * 3 + 2] = beachWaveY(seaWorldX + lx, midZ - ly, time);
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
foamMat.opacity = 0.55 + Math.sin(time * 1.7) * 0.2;
|
||||
|
||||
for (const b of bathers) {
|
||||
b.mesh.position.y = -0.28 + Math.sin(time * 1.6 + b.phase) * 0.07;
|
||||
b.mesh.position.x = b.baseX + Math.sin(time * 0.5 + b.phase) * 0.7;
|
||||
b.mesh.rotation.y = Math.PI / 2 + Math.sin(time * 0.4 + b.phase) * 0.5;
|
||||
}
|
||||
for (const s of swimmers) {
|
||||
s.mesh.position.y = -0.55 + Math.sin(time * 2 + s.phase) * 0.1;
|
||||
s.mesh.position.z = s.baseZ + Math.sin(time * 0.35 + s.phase) * 5;
|
||||
}
|
||||
for (const s of surfers) {
|
||||
const w = time * 0.14 + s.phase;
|
||||
s.mesh.position.z = s.baseZ + Math.sin(w) * s.span;
|
||||
s.mesh.position.x = s.baseX + Math.cos(w) * 5;
|
||||
s.mesh.position.y = 0.14 + beachWaveY(s.mesh.position.x, s.mesh.position.z, time);
|
||||
// Face the direction of travel (derivative of the path).
|
||||
s.mesh.rotation.y = Math.atan2(
|
||||
-Math.sin(w) * 5 * 0.14,
|
||||
Math.cos(w) * s.span * 0.14
|
||||
);
|
||||
}
|
||||
|
||||
// Basket players patrol the court; the ball follows the lead
|
||||
// player and takes an arc shot whenever they reach a hoop.
|
||||
for (const p of basket) {
|
||||
p.z += p.dir * p.speed * dt;
|
||||
if (p.z >= p.endB) { p.z = p.endB; p.dir = -1; }
|
||||
if (p.z <= p.endA) { p.z = p.endA; p.dir = 1; }
|
||||
p.mesh.position.z = p.z;
|
||||
p.mesh.rotation.y = p.dir > 0 ? 0 : Math.PI;
|
||||
}
|
||||
const lead = basket[shot.target % basket.length];
|
||||
if (shot.phase === 'follow') {
|
||||
ball.position.set(
|
||||
lead.mesh.position.x + 0.45,
|
||||
0.95 + Math.abs(Math.sin(time * 5)) * 0.5,
|
||||
lead.mesh.position.z + 0.2
|
||||
);
|
||||
shot.timer -= dt;
|
||||
const nearHoop = Math.abs(lead.z - lead.endA) < 1.6 || Math.abs(lead.z - lead.endB) < 1.6;
|
||||
if (shot.timer <= 0 && nearHoop) {
|
||||
const hoopZ = Math.abs(lead.z - lead.endA) < 1.6 ? lead.endA : lead.endB;
|
||||
shot.phase = 'shoot';
|
||||
shot.t = 0;
|
||||
shot.from.copy(ball.position);
|
||||
shot.to.set(courtX, 2.9, hoopZ + (hoopZ < courtZ ? 0.75 : -0.75));
|
||||
shot.timer = 6 + Math.random() * 3;
|
||||
} else if (shot.timer <= 0) {
|
||||
shot.timer = 2; // retry soon when they reach an end
|
||||
}
|
||||
} else if (shot.phase === 'shoot') {
|
||||
shot.t += dt / 1.1;
|
||||
const t = Math.min(1, shot.t);
|
||||
ball.position.lerpVectors(shot.from, shot.to, t);
|
||||
ball.position.y += Math.sin(Math.PI * t) * 1.4;
|
||||
if (t >= 1) {
|
||||
shot.phase = 'return';
|
||||
shot.t = 0;
|
||||
shot.from.copy(ball.position);
|
||||
shot.target = (shot.target + 1) % basket.length;
|
||||
const next = basket[shot.target];
|
||||
shot.to.set(next.mesh.position.x + 0.45, 1.1, next.mesh.position.z);
|
||||
}
|
||||
} else { // return/pass
|
||||
shot.t += dt / 0.9;
|
||||
const t = Math.min(1, shot.t);
|
||||
ball.position.lerpVectors(shot.from, shot.to, t);
|
||||
ball.position.y += Math.sin(Math.PI * t) * 0.8;
|
||||
if (t >= 1) shot.phase = 'follow';
|
||||
}
|
||||
|
||||
// Lifeguards: the tower guard scans the water, the other
|
||||
// patrols the shoreline facing the sea.
|
||||
towerGuard.rotation.y = Math.sin(time * 0.5) * 0.7;
|
||||
patrolGuard.position.z = Math.sin(time * 0.16) * 70;
|
||||
patrolGuard.position.x = sandTo - 9 + Math.sin(time * 0.3) * 1.5;
|
||||
patrolGuard.rotation.y = Math.PI / 2 + Math.sin(time * 0.16) * 0.15;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nearestGymEquipment(maxDist = 2.8) {
|
||||
let best = null;
|
||||
for (const eq of gymEquipment) {
|
||||
@@ -2842,13 +3269,97 @@ async function stopExerciseSession(showMsg = true) {
|
||||
if (meCharacter && meCharacter.state === 'exercising') meCharacter.state = 'awake';
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Beach relax session: sit on the sand and recover a bit per server tick.
|
||||
// ------------------------------------------------------------------
|
||||
function animateRelax(dt) {
|
||||
relax.animPhase += dt;
|
||||
if (!meMesh) return;
|
||||
const u = meMesh.userData;
|
||||
if (!u?.legs || !u?.arms) return;
|
||||
// Sitting with legs stretched forward and gentle breathing.
|
||||
u.legs[0].rotation.x = -1.35;
|
||||
u.legs[1].rotation.x = -1.35;
|
||||
u.arms[0].rotation.x = 0.25;
|
||||
u.arms[1].rotation.x = 0.25;
|
||||
u.arms[0].rotation.z = -0.18;
|
||||
u.arms[1].rotation.z = 0.18;
|
||||
meMesh.position.y = 0.42 + Math.sin(relax.animPhase * 1.6) * 0.02;
|
||||
}
|
||||
|
||||
function stopRelaxSession() {
|
||||
if (!relax.active) return;
|
||||
relax.active = false;
|
||||
if (meMesh) {
|
||||
meMesh.position.y = 0;
|
||||
if (meMesh.userData?.legs) meMesh.userData.legs.forEach(l => { l.rotation.x = 0; });
|
||||
if (meMesh.userData?.arms) meMesh.userData.arms.forEach(ar => {
|
||||
ar.rotation.x = 0;
|
||||
ar.rotation.z = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function startRelaxSession() {
|
||||
if (!meCharacter || relax.active || exercise.active) return;
|
||||
relax.active = true;
|
||||
relax.timer = 0;
|
||||
relax.animPhase = 0;
|
||||
flashCharMsg(i18n.beachStart || 'Resting on the sand…');
|
||||
cityAudio.play('ok');
|
||||
}
|
||||
|
||||
function updateExercise(dt) {
|
||||
if (isHospitalizedNow()) {
|
||||
if (exercise.active) stopExerciseSession(false);
|
||||
if (relax.active) stopRelaxSession();
|
||||
setExercisePrompt(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Beach leisure (east shore) ----
|
||||
const onBeach = !!beach
|
||||
&& player.pos.x >= beach.sand_from - 1
|
||||
&& player.pos.x <= beach.sand_to + 2
|
||||
&& player.pos.z >= beach.z_min
|
||||
&& player.pos.z <= beach.z_max;
|
||||
const isMoving = keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
||||
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight'];
|
||||
|
||||
if (relax.active) {
|
||||
if (isMoving || !onBeach) {
|
||||
stopRelaxSession();
|
||||
setExercisePrompt(null);
|
||||
} else {
|
||||
setExercisePrompt(`🏖️ ${i18n.beachActive || 'Relaxing at the beach'} · E ${i18n.exerciseStopHint || 'stop'}`);
|
||||
animateRelax(dt);
|
||||
relax.timer += dt;
|
||||
if (relax.timer >= 1.5) {
|
||||
relax.timer = 0;
|
||||
postJson('/city/actions/beach', {
|
||||
pos_x: player.pos.x,
|
||||
pos_z: player.pos.z,
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.character) updateCharacterHud(data.character);
|
||||
if (data.full) {
|
||||
flashCharMsg(data.message);
|
||||
stopRelaxSession();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
flashCharMsg(e.message, true);
|
||||
stopRelaxSession();
|
||||
});
|
||||
}
|
||||
if (keys['KeyE']) {
|
||||
keys['KeyE'] = false;
|
||||
stopRelaxSession();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const near = spawnApplied ? nearestGymEquipment(2.8) : null;
|
||||
|
||||
if (exercise.active) {
|
||||
@@ -2895,6 +3406,8 @@ function updateExercise(dt) {
|
||||
|
||||
if (near && meCharacter && meCharacter.state !== 'sleeping') {
|
||||
setExercisePrompt(`🏋️ ${i18n.exerciseUse || 'Press E to exercise'} · ${near.eq.kind}`);
|
||||
} else if (onBeach && meCharacter && meCharacter.state !== 'sleeping') {
|
||||
setExercisePrompt(`🏖️ ${i18n.beachPrompt || 'Relax at the beach'} · E`);
|
||||
} else {
|
||||
setExercisePrompt(null);
|
||||
}
|
||||
@@ -2906,6 +3419,8 @@ function updateExercise(dt) {
|
||||
stopExerciseSession();
|
||||
} else if (near) {
|
||||
startExerciseSession(near.eq);
|
||||
} else if (onBeach && meCharacter && meCharacter.state !== 'sleeping') {
|
||||
startRelaxSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2933,6 +3448,9 @@ function buildCity(data) {
|
||||
plazas = [];
|
||||
gymEquipment = [];
|
||||
exercise = { active: false, equipmentId: null, repTimer: 0, animPhase: 0, nearId: null };
|
||||
relax = { active: false, timer: 0, animPhase: 0 };
|
||||
beach = null;
|
||||
beachSystem = null;
|
||||
|
||||
const layout = data.layout || { roads: [], blocks: [] };
|
||||
const stores = data.stores || [];
|
||||
@@ -3259,6 +3777,10 @@ function buildCity(data) {
|
||||
worldGroup.add(ring);
|
||||
});
|
||||
|
||||
// ---- Beach (east shore) ----
|
||||
beach = data.beach || null;
|
||||
beachSystem = beach ? initBeach(beach) : null;
|
||||
|
||||
// ---- Filler buildings ----
|
||||
// Fillers only exist on empty ground: skip cells near stores AND near
|
||||
// any placed building (hospital, school, city hall, player houses) so
|
||||
@@ -3801,9 +4323,15 @@ function updatePlayer(dt) {
|
||||
player.onGround = false;
|
||||
}
|
||||
|
||||
// City bounds, extended over the east beach: players may walk onto
|
||||
// the sand and wade at the shoreline, but never into the deep sea.
|
||||
let maxX = cityBounds ? cityBounds.max_x : 400;
|
||||
if (beach && player.pos.z >= beach.z_min && player.pos.z <= beach.z_max) {
|
||||
maxX = Math.max(maxX, beach.sand_to + 1);
|
||||
}
|
||||
player.pos.x = cityBounds
|
||||
? Math.max(cityBounds.min_x, Math.min(cityBounds.max_x, player.pos.x))
|
||||
: Math.max(-400, Math.min(400, player.pos.x));
|
||||
? Math.max(cityBounds.min_x, Math.min(maxX, player.pos.x))
|
||||
: Math.max(-400, Math.min(maxX, player.pos.x));
|
||||
player.pos.z = cityBounds
|
||||
? Math.max(cityBounds.min_z, Math.min(cityBounds.max_z, player.pos.z))
|
||||
: Math.max(-400, Math.min(400, player.pos.z));
|
||||
@@ -4441,6 +4969,7 @@ function animate() {
|
||||
if (airplaneSystem) airplaneSystem.update(dt);
|
||||
if (trainSystem) trainSystem.update(dt);
|
||||
if (riverSystem) riverSystem.update(dt, simTimeAcc);
|
||||
if (beachSystem) beachSystem.update(dt, simTimeAcc);
|
||||
|
||||
updatePlayer(dt);
|
||||
checkVehicleHitOnPlayer();
|
||||
@@ -4942,6 +5471,10 @@ window.__city10 = {
|
||||
return out;
|
||||
},
|
||||
playerCrossingAllowed,
|
||||
/** Current bump-to-talk peer (null when the chat is closed). */
|
||||
get talkPeer() {
|
||||
return currentPeer;
|
||||
},
|
||||
/** Local player pose for movement/look tests. */
|
||||
playerPose() {
|
||||
return {
|
||||
@@ -4996,6 +5529,8 @@ window.__city10 = {
|
||||
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
|
||||
crosswalks: crosswalkCount,
|
||||
sidewalkSegments: sidewalkSegmentCount,
|
||||
beach: beachSystem ? beachSystem.counts() : null,
|
||||
remoteIds: [...remoteCitizens.keys()],
|
||||
render: {
|
||||
calls: renderer.info.render.calls,
|
||||
triangles: renderer.info.render.triangles,
|
||||
|
||||
@@ -365,6 +365,9 @@
|
||||
hospitalReleased: @json(__('city.hospital_released')),
|
||||
exerciseUse: @json(__('city.exercise_use')),
|
||||
exerciseActive: @json(__('city.exercise_active')),
|
||||
beachStart: @json(__('city.beach_start')),
|
||||
beachPrompt: @json(__('city.beach_prompt')),
|
||||
beachActive: @json(__('city.beach_active')),
|
||||
exerciseStopHint: @json(__('city.exercise_stopHint')),
|
||||
exerciseStart: @json(__('city.exerciseStart')),
|
||||
exerciseStop: @json(__('city.exerciseStop')),
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Character;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\CityWorldSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* East-beach leisure action and its geometry in the world payload.
|
||||
*/
|
||||
class BeachLeisureTest 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The world payload carries the beach geometry derived from the grid.
|
||||
*/
|
||||
public function test_city_data_includes_beach_geometry(): void
|
||||
{
|
||||
$payload = $this->getJson('/city/data')->assertOk()->json();
|
||||
|
||||
$this->assertArrayHasKey('beach', $payload);
|
||||
// Layout max X (200 for these bounds) + approach → sand starts at 204.
|
||||
$this->assertEquals(204.0, $payload['beach']['sand_from']);
|
||||
$this->assertEquals(264.0, $payload['beach']['sand_to']);
|
||||
$this->assertEquals(354.0, $payload['beach']['sea_to']);
|
||||
$this->assertEquals(-160.0, $payload['beach']['z_min']);
|
||||
$this->assertEquals(160.0, $payload['beach']['z_max']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relaxing on the sand recovers one point of energy and health.
|
||||
*/
|
||||
public function test_relax_on_sand_recovers_energy_and_health(): void
|
||||
{
|
||||
$this->character->forceFill(['energy' => 40, 'health' => 50])->save();
|
||||
|
||||
$this->postJson('/city/actions/beach', ['pos_x' => 210, 'pos_z' => -40])
|
||||
->assertOk()
|
||||
->assertJsonPath('message', __('city.beach_ok'));
|
||||
|
||||
$this->character->refresh();
|
||||
$this->assertSame(41, (int) $this->character->energy);
|
||||
$this->assertSame(51, (int) $this->character->health);
|
||||
}
|
||||
|
||||
/**
|
||||
* The action is rejected away from the beach.
|
||||
*/
|
||||
public function test_relax_requires_being_on_the_beach(): void
|
||||
{
|
||||
$this->postJson('/city/actions/beach', ['pos_x' => 0, 'pos_z' => 0])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', __('city.beach_need'));
|
||||
|
||||
$this->postJson('/city/actions/beach', ['pos_x' => 300, 'pos_z' => 0])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', __('city.beach_need'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully restored characters are told they are already at max.
|
||||
*/
|
||||
public function test_relax_at_max_stats_reports_full(): void
|
||||
{
|
||||
$this->character->forceFill(['energy' => 100, 'health' => 100])->save();
|
||||
|
||||
$this->postJson('/city/actions/beach', ['pos_x' => 220, 'pos_z' => 30])
|
||||
->assertOk()
|
||||
->assertJsonPath('message', __('city.beach_full'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleeping characters cannot relax.
|
||||
*/
|
||||
public function test_sleeping_character_cannot_relax(): void
|
||||
{
|
||||
$this->character->forceFill(['state' => Character::STATE_SLEEPING])->save();
|
||||
|
||||
$this->postJson('/city/actions/beach', ['pos_x' => 210, 'pos_z' => 0])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', __('city.exercise_need_awake'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Position sync persists up to the shoreline (and no further).
|
||||
*/
|
||||
public function test_position_persists_onto_the_beach(): void
|
||||
{
|
||||
$onSand = $this->postJson('/city/actions/position', ['pos_x' => 250, 'pos_z' => 0])
|
||||
->assertOk();
|
||||
$this->assertEquals(250, $onSand->json('pos_x'));
|
||||
|
||||
$pastShore = $this->postJson('/city/actions/position', ['pos_x' => 400, 'pos_z' => 0])
|
||||
->assertOk();
|
||||
$this->assertEquals(266, $pastShore->json('pos_x'));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\InventoryItem;
|
||||
use App\Models\Job;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Support\Beach;
|
||||
use Database\Seeders\CityWorldSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
@@ -56,7 +57,8 @@ class CityActionsTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Position updates clamp inside city bounds and persist.
|
||||
* Position updates clamp to the beach shoreline (past the road grid)
|
||||
* and persist; other axes clamp to the city bounds.
|
||||
*/
|
||||
public function test_position_persists_and_clamps(): void
|
||||
{
|
||||
@@ -65,7 +67,7 @@ class CityActionsTest extends TestCase
|
||||
|
||||
$this->character->refresh();
|
||||
$city = $this->character->city;
|
||||
$this->assertSame((float) $city->bounds_max_x, $this->character->pos_x);
|
||||
$this->assertSame(Beach::sandTo($city) + 2, $this->character->pos_x);
|
||||
$this->assertSame((float) $city->bounds_min_z, $this->character->pos_z);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user