diff --git a/.playwright-mcp/praia-barracas-cadeiras-quadra.png b/.playwright-mcp/praia-barracas-cadeiras-quadra.png new file mode 100644 index 0000000..2d000a3 Binary files /dev/null and b/.playwright-mcp/praia-barracas-cadeiras-quadra.png differ diff --git a/.playwright-mcp/praia-ondas-surfistas.png b/.playwright-mcp/praia-ondas-surfistas.png new file mode 100644 index 0000000..ed8e9ae Binary files /dev/null and b/.playwright-mcp/praia-ondas-surfistas.png differ diff --git a/app/Http/Controllers/Web/CityActionController.php b/app/Http/Controllers/Web/CityActionController.php index 10ffc04..f48a508 100755 --- a/app/Http/Controllers/Web/CityActionController.php +++ b/app/Http/Controllers/Web/CityActionController.php @@ -9,6 +9,7 @@ use App\Models\City; use App\Models\InventoryItem; use App\Models\Job; use App\Models\Transaction; +use App\Support\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. */ diff --git a/app/Http/Controllers/Web/CityViewerController.php b/app/Http/Controllers/Web/CityViewerController.php index 4c6bea4..d4eeaa9 100755 --- a/app/Http/Controllers/Web/CityViewerController.php +++ b/app/Http/Controllers/Web/CityViewerController.php @@ -9,6 +9,7 @@ use App\Models\Character; use App\Models\City; use App\Models\Job; use App\Models\Store; +use App\Support\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, diff --git a/app/Support/Beach.php b/app/Support/Beach.php new file mode 100644 index 0000000..d919b51 --- /dev/null +++ b/app/Support/Beach.php @@ -0,0 +1,87 @@ +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; + } +} diff --git a/lang/en.json b/lang/en.json index ade7ed1..1b1889c 100755 --- a/lang/en.json +++ b/lang/en.json @@ -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.", diff --git a/lang/es.json b/lang/es.json index e3ae985..8e98ad1 100755 --- a/lang/es.json +++ b/lang/es.json @@ -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.", diff --git a/lang/pt_BR.json b/lang/pt_BR.json index fe304e1..993e441 100755 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -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.", diff --git a/public/js/city/app.js b/public/js/city/app.js index afbf4ce..cd6dff9 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -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, diff --git a/resources/views/city/viewer.blade.php b/resources/views/city/viewer.blade.php index 7a3eefc..d30452a 100755 --- a/resources/views/city/viewer.blade.php +++ b/resources/views/city/viewer.blade.php @@ -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')), diff --git a/tests/Feature/BeachLeisureTest.php b/tests/Feature/BeachLeisureTest.php new file mode 100644 index 0000000..196caa0 --- /dev/null +++ b/tests/Feature/BeachLeisureTest.php @@ -0,0 +1,117 @@ +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')); + } +} diff --git a/tests/Feature/CityActionsTest.php b/tests/Feature/CityActionsTest.php index 367b5eb..3ec0776 100755 --- a/tests/Feature/CityActionsTest.php +++ b/tests/Feature/CityActionsTest.php @@ -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); }