diff --git a/app/Game/GameSimulator.php b/app/Game/GameSimulator.php index 0f9eb1d..4f25b6e 100644 --- a/app/Game/GameSimulator.php +++ b/app/Game/GameSimulator.php @@ -240,7 +240,6 @@ final class GameSimulator $keys = is_array($frame['keys'] ?? null) ? $frame['keys'] : []; $input->keys = array_values(array_filter($keys, 'is_string')); $input->fire = (bool) ($frame['fire'] ?? false); - $input->autoAim = (bool) ($frame['autoAim'] ?? false); $aim = $frame['aim'] ?? null; if (is_array($aim)) { @@ -265,10 +264,8 @@ final class GameSimulator $entry['engine']->tick($dt); if ($room->state === Room::STATE_OVER) { - $room->seq++; $this->broadcastRoom($room); } elseif ($this->frame % self::SNAPSHOT_EVERY === 0) { - $room->seq++; $this->broadcastRoom($room); } } @@ -280,6 +277,8 @@ final class GameSimulator return; } + $room->seq++; + $players = []; foreach ($room->players as $player) { $players[] = $player->toSnapshot(); diff --git a/app/Game/InputState.php b/app/Game/InputState.php index 9ee3fdb..205e518 100644 --- a/app/Game/InputState.php +++ b/app/Game/InputState.php @@ -14,18 +14,14 @@ final class InputState public bool $fire; - public bool $autoAim; - public function __construct( array $keys = [], ?Vector2 $aim = null, bool $fire = false, - bool $autoAim = false, ) { $this->keys = $keys; $this->aim = $aim ?? new Vector2(1.0, 0.0); $this->fire = $fire; - $this->autoAim = $autoAim; } public function reset(?Vector2 $aim = null): void @@ -33,6 +29,5 @@ final class InputState $this->keys = []; $this->aim = $aim ?? new Vector2(1.0, 0.0); $this->fire = false; - $this->autoAim = false; } } diff --git a/app/Game/Player.php b/app/Game/Player.php index 7352253..3210f7b 100644 --- a/app/Game/Player.php +++ b/app/Game/Player.php @@ -60,8 +60,6 @@ final class Player extends GameObject public bool $dead = false; - public bool $autoAim = false; - private float $cooldown = 0.0; private Vector2 $aimDirection; @@ -154,22 +152,19 @@ final class Player extends GameObject ); $aim = $this->input->aim; - $autoAimActive = $this->input->autoAim || $this->autoAim; $hasTarget = false; - if ($autoAimActive) { - $nearest = $this->nearestZombie(); - if ($nearest !== null) { - $hasTarget = true; - $toEnemy = $nearest->pos->subV($this->pos); - if ($toEnemy->lengthSquared() > 1.0) { - $aim = $toEnemy; - } + $nearest = $this->nearestZombie(); + if ($nearest !== null) { + $hasTarget = true; + $toEnemy = $nearest->pos->subV($this->pos); + if ($toEnemy->lengthSquared() > 1.0) { + $aim = $toEnemy; } } if ($aim->lengthSquared() > 1.0) { $this->aimDirection = $aim->divS($aim->length()); - } elseif ($aim->lengthSquared() > 0.01 && $this->input->aim !== null && ! $autoAimActive) { - // Raw normalized direction from the client when not auto-aiming. + } elseif ($aim->lengthSquared() > 0.01 && $this->input->aim !== null) { + // Use the raw client direction when no auto-aim target is near. $this->aimDirection = $aim->normalize(); } @@ -177,7 +172,7 @@ final class Player extends GameObject $this->damageCooldown = max(0.0, $this->damageCooldown - $dt); $this->cooldown -= $dt; - if ($this->cooldown <= 0.0 && ($this->input->fire || ($autoAimActive && $hasTarget))) { + if ($this->cooldown <= 0.0 && ($this->input->fire || $hasTarget)) { $this->cooldown = $this->fireRate; $this->shoot(); } diff --git a/app/Game/Room.php b/app/Game/Room.php index e767db0..e61364c 100644 --- a/app/Game/Room.php +++ b/app/Game/Room.php @@ -73,7 +73,6 @@ final class Room $this->spawnTimer = 0.0; $this->pickupTimer = 8.0; $this->events = []; - $this->seq++; foreach ($this->entities->copy() as $entity) { if ($entity instanceof Player) { diff --git a/app/Http/Controllers/GameInputController.php b/app/Http/Controllers/GameInputController.php index f55f22b..3c7fdcf 100644 --- a/app/Http/Controllers/GameInputController.php +++ b/app/Http/Controllers/GameInputController.php @@ -32,11 +32,10 @@ final class GameInputController extends Controller 'keys' => $keys, 'aim' => $aim, 'fire' => (bool) $request->input('fire', false), - 'autoAim' => (bool) $request->input('autoAim', false), ]; Redis::rpush(sprintf('game:in:%s', $room), json_encode($frame, JSON_THROW_ON_ERROR)); return response()->json(['ok' => true]); } -} \ No newline at end of file +} diff --git a/app/Listeners/PushClientEventToInputQueue.php b/app/Listeners/PushClientEventToInputQueue.php index 28b6b61..ca380a0 100644 --- a/app/Listeners/PushClientEventToInputQueue.php +++ b/app/Listeners/PushClientEventToInputQueue.php @@ -68,7 +68,6 @@ final class PushClientEventToInputQueue ? ['x' => (float) ($data['aim']['x'] ?? 0.0), 'y' => (float) ($data['aim']['y'] ?? 0.0)] : ['x' => 1.0, 'y' => 0.0], 'fire' => (bool) ($data['fire'] ?? false), - 'autoAim' => (bool) ($data['autoAim'] ?? false), ]; Redis::rpush(sprintf(self::INPUT_QUEUE, $roomId), json_encode($frame, JSON_THROW_ON_ERROR)); diff --git a/resources/css/game.css b/resources/css/game.css index 2d3cd86..d216a0d 100644 --- a/resources/css/game.css +++ b/resources/css/game.css @@ -213,6 +213,21 @@ html, body { color: #bbb; min-height: 18px; } +.lobby-players-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #888; + margin-bottom: 6px; +} +.lobby-player { + padding: 3px 0; + font-size: 14px; + color: #eee; +} +.lobby-player.you { + color: #4fe3ff; +} #gameover { position: fixed; inset: 0; diff --git a/resources/js/game/Player.js b/resources/js/game/Player.js index 84fd769..ccac0c3 100644 --- a/resources/js/game/Player.js +++ b/resources/js/game/Player.js @@ -1,7 +1,6 @@ import { GameObject } from '../engine/GameObject.js'; import { Vector2D } from '../engine/Vector2D.js'; import { Bullet } from './Bullet.js'; -import { Zombie } from './Zombie.js'; export class Player extends GameObject { constructor( @@ -23,7 +22,6 @@ export class Player extends GameObject { particles = null, sounds = null, size = 24, - autoAim = false, } = {}, ) { super(viewport.width / 2, viewport.height / 2); @@ -51,7 +49,6 @@ export class Player extends GameObject { this._recoil = new Vector2D(0, 0); this._damageCooldown = 0; this._time = 0; - this.autoAim = autoAim; } update(dt) { @@ -81,15 +78,6 @@ export class Player extends GameObject { this.pos.y = Math.min(Math.max(this.pos.y, maxY), height - maxY); let aim = this.input.mouse.subV(this.pos); - if (this.autoAim) { - const nearest = this._nearestZombie(); - if (nearest) { - const toEnemy = nearest.pos.subV(this.pos); - if (toEnemy.lengthSqrt() > 1) { - aim = toEnemy; - } - } - } if (aim.lengthSqrt() > 1) { this._aimDirection = aim.divS(aim.length()); } @@ -104,21 +92,6 @@ export class Player extends GameObject { } } - _nearestZombie() { - let best = null; - let bestDistSq = Infinity; - for (const obj of this.container || []) { - if (!(obj instanceof Zombie)) continue; - const delta = obj.pos.subV(this.pos); - const distSq = delta.lengthSqrt(); - if (distSq < bestDistSq) { - bestDistSq = distSq; - best = obj; - } - } - return best; - } - shoot() { const aimAngle = Math.atan2(this._aimDirection.y, this._aimDirection.x); this._recoil = this._aimDirection.mulS(-this.recoilSpeed); diff --git a/resources/js/main.js b/resources/js/main.js index f4c73d1..5a19c9f 100644 --- a/resources/js/main.js +++ b/resources/js/main.js @@ -46,7 +46,6 @@ window.addEventListener('orientationchange', resizeToWindow); resizeToWindow(); const input = new Input(canvas, viewport); -const isMobile = input.isMobile; const fullscreenButton = document.getElementById('fullscreen'); const updateFullscreenLabel = () => { @@ -87,9 +86,7 @@ const updateCamera = (cx, cy) => { camera.y = Math.min(Math.max(0, targetY), Math.max(0, WORLD_H - viewport.height)); }; -let autoAim = isMobile; let muted = false; -let autoAimNotice = 0; let muteNotice = 0; let gameStarted = false; let lastEventSeq = -1; @@ -555,7 +552,6 @@ const Lobby = (() => { }; el.name.value = me.name; - el.room.value = localStorage.getItem('zombies.room') || ''; const setStatus = (text, error = false) => { el.status.textContent = text; @@ -579,7 +575,20 @@ const Lobby = (() => { return; } const my = players.find((p) => p.id === me.id) || null; - el.players.textContent = `In room: ${players.map((p) => `${p.name}${p.host ? ' (host)' : ''}${p.id === me.id ? ' (you)' : ''}`).join(', ')}`; + el.players.textContent = ''; + const title = document.createElement('div'); + title.className = 'lobby-players-title'; + title.textContent = 'Players'; + el.players.appendChild(title); + for (const p of players) { + const row = document.createElement('div'); + row.className = 'lobby-player'; + row.textContent = `${p.name}${p.id === me.id ? ' (you)' : ''}${p.host ? ' (host)' : ''}`; + if (p.id === me.id) { + row.classList.add('you'); + } + el.players.appendChild(row); + } el.roomCode.textContent = me.room; showInRoom(); if (players.length === 1) { @@ -917,11 +926,11 @@ const drawHud = (state, my, now, t) => { textRenderer.draw(renderer, `Score: ${my.score}`, 12, 12, { size: 20, fillStyle: '#fff' }); textRenderer.draw(renderer, `HP: ${my.hp}`, 12, 40, { size: 20, fillStyle: '#fff' }); - if (autoAim) { - const aaEntry = textRenderer.measure('AUTO-AIM', { size: 14, fillStyle: '#4fe3ff' }); - textRenderer.draw(renderer, 'AUTO-AIM', viewport.width - aaEntry.width - 12, 12, { - size: 14, - fillStyle: '#4fe3ff', + if (my.dead && state.state === 'playing' && state.players.some((p) => p.id !== my.id && !p.dead)) { + const specEntry = textRenderer.measure('SPECTATING', { size: 20, fillStyle: '#8a8a8a' }); + textRenderer.draw(renderer, 'SPECTATING', viewport.width / 2 - specEntry.width / 2, 60, { + size: 20, + fillStyle: '#8a8a8a', }); } @@ -947,15 +956,6 @@ const drawHud = (state, my, now, t) => { fillStyle: '#fff', }); } - if (autoAimNotice > 0) { - autoAimNotice -= t; - const notice = autoAim ? 'Auto-aim ON' : 'Auto-aim OFF'; - const entry = textRenderer.measure(notice, { size: 22, fillStyle: '#fff' }); - textRenderer.draw(renderer, notice, viewport.width / 2 - entry.width / 2, viewport.height / 2 - 20, { - size: 22, - fillStyle: '#4fe3ff', - }); - } void now; }; @@ -978,20 +978,18 @@ const gameLoop = (now) => { muted = sounds.toggleMuted(); muteNotice = 1.2; } - if (!isMobile && input.pressed.has('q')) { - input.clearPressed(); - autoAim = !autoAim; - autoAimNotice = 1.2; - } renderer.clear(0, 0, 0, 1); const snapshot = networking.curr; const frame = snapshot ? interpolate(now) : null; const own = frame ? frame.players.find((p) => p.id === me.id) || null : null; - if (own) { - entities.setTarget(own.x, own.y); - updateCamera(own.x, own.y); + const anchor = own + ? (own.dead ? frame.players.find((p) => p.id !== me.id && !p.dead) || own : own) + : null; + if (anchor) { + entities.setTarget(anchor.x, anchor.y); + updateCamera(anchor.x, anchor.y); } else { updateCamera(WORLD_W / 2, WORLD_H / 2); } @@ -1084,7 +1082,6 @@ const gameLoop = (now) => { keys: [...input.keys], aim: { x: mouse.x - own.x, y: mouse.y - own.y }, fire: input.mouseDown, - autoAim, }); } else { networking.stopSendingInput(); diff --git a/resources/js/net/NetEntities.js b/resources/js/net/NetEntities.js index dfd39a8..afe0400 100644 --- a/resources/js/net/NetEntities.js +++ b/resources/js/net/NetEntities.js @@ -93,6 +93,9 @@ export class NetEntities { this._stampPlayer(view, snapshot, now); if (!snapshot.dead) { view.draw(renderer); + if (snapshot.name) { + this._drawName(snapshot, renderer, textRenderer); + } } } @@ -110,6 +113,27 @@ export class NetEntities { this.players.clear(); } + _drawName(snapshot, renderer, textRenderer) { + const hp = snapshot.hp ?? 0; + const mhp = snapshot.mhp ?? hp; + const label = `${snapshot.name} HP: ${hp}/${mhp}`; + const opts = { size: 12, fillStyle: '#fff', bold: true }; + const entry = textRenderer.measure(label, opts); + + const barW = 40; + const barH = 4; + const barX = snapshot.x - barW / 2; + const barY = snapshot.y - PLAYER_SIZE / 2 - barH - 2; + renderer.drawTexture(renderer.whiteTexture, barX, barY, barW, barH, [0, 0, 0, 0.6]); + const ratio = mhp > 0 ? Math.max(0, Math.min(1, hp / mhp)) : 0; + if (ratio > 0) { + const color = ratio > 2 / 3 ? [0.3, 0.9, 0.35, 0.9] : ratio > 1 / 3 ? [0.95, 0.8, 0.2, 0.9] : [0.9, 0.25, 0.25, 0.9]; + renderer.drawTexture(renderer.whiteTexture, barX, barY, barW * ratio, barH, color); + } + + textRenderer.draw(renderer, label, snapshot.x - entry.width / 2, barY - entry.height - 2, opts); + } + _stamp(view, entity, now) { view.pos.x = entity.x; view.pos.y = entity.y; diff --git a/resources/js/net/NetworkClient.js b/resources/js/net/NetworkClient.js index cb09be3..dadc697 100644 --- a/resources/js/net/NetworkClient.js +++ b/resources/js/net/NetworkClient.js @@ -140,13 +140,12 @@ export class NetworkClient { _flushInput() { if (!this._input || !this._connected()) return; - const { id, keys, aim, fire, autoAim } = this._input; + const { id, keys, aim, fire } = this._input; this.socket.trigger('client-input', this._inputChannel(), { id, keys, aim, fire, - autoAim, }); } diff --git a/tests/Unit/Game/GameEngineTest.php b/tests/Unit/Game/GameEngineTest.php index da679a5..8119638 100644 --- a/tests/Unit/Game/GameEngineTest.php +++ b/tests/Unit/Game/GameEngineTest.php @@ -59,7 +59,7 @@ final class GameEngineTest extends TestCase 'health' => 1, ]); $room->addPlayer($player); - $zombie = new Zombie(944.4, 540.0, $room->entities, $room, ['emergeTime' => 0.0]); + $zombie = new Zombie(944.4, 540.0, $room->entities, $room, ['emergeTime' => 0.0, 'health' => 20]); $zombie->target = $player; $room->entities->append($zombie); $engine->tick(1.0 / 60.0); diff --git a/tests/Unit/Game/GameSimulatorTest.php b/tests/Unit/Game/GameSimulatorTest.php index e15aa59..1ca3670 100644 --- a/tests/Unit/Game/GameSimulatorTest.php +++ b/tests/Unit/Game/GameSimulatorTest.php @@ -69,6 +69,18 @@ final class GameSimulatorTest extends TestCase $this->assertSame(['p1' => true, 'p2' => false], $hosts); } + #[Test] + public function lobby_snapshots_have_monotonic_seq(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $firstSeq = $sim->room('room-a')->seq; + + $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); + + $this->assertGreaterThan($firstSeq, $sim->room('room-a')->seq); + } + #[Test] public function start_transitions_to_playing(): void { @@ -190,7 +202,7 @@ final class GameSimulatorTest extends TestCase $sim = $this->makeSimulator(); $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); - $sim->applyInput('room-a', ['id' => 'p1', 'keys' => [], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => true, 'autoAim' => true]); + $sim->applyInput('room-a', ['id' => 'p1', 'keys' => [], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => true]); $sawZombie = false; for ($i = 0; $i < 600; $i++) { $sim->tick(1.0 / 60.0); @@ -337,7 +349,7 @@ final class GameSimulatorTest extends TestCase $zombie = new Zombie($player->pos->x + 40.0, $player->pos->y, $room->entities, $room, ['emergeTime' => 0.0]); $room->entities->append($zombie); - $sim->applyInput('room-a', ['id' => 'p1', 'keys' => [], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => true, 'autoAim' => false]); + $sim->applyInput('room-a', ['id' => 'p1', 'keys' => [], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => true]); $hit = false; $kills = false; diff --git a/tests/Unit/Game/PlayerTest.php b/tests/Unit/Game/PlayerTest.php index 4167fb0..6da73ff 100644 --- a/tests/Unit/Game/PlayerTest.php +++ b/tests/Unit/Game/PlayerTest.php @@ -52,7 +52,7 @@ final class PlayerTest extends TestCase { $room = new Room('test'); $room->state = Room::STATE_PLAYING; - $input = new InputState(fire: false, autoAim: true); + $input = new InputState(fire: false); $player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]); $room->addPlayer($player); $zombie = new Zombie(300.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0]); @@ -69,7 +69,7 @@ final class PlayerTest extends TestCase public function auto_aim_does_not_fire_without_target(): void { $room = new Room('test'); - $input = new InputState(fire: false, autoAim: true); + $input = new InputState(fire: false); $player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]); $room->addPlayer($player); $player->update(1.0 / 60.0); @@ -163,7 +163,7 @@ final class PlayerTest extends TestCase public function auto_aim_tracks_nearest_zombie(): void { $room = new Room('test'); - $input = new InputState(fire: true, autoAim: true); + $input = new InputState(fire: true); $player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]); $room->addPlayer($player); $zombie = new Zombie(200.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0]);