local World = require("game.world") local Player = {} -- Tank-feel constants tuned for the authentic 1980 arcade rhythm: deliberate -- movement, slow rotation, one-track drive is a partial speed boost. local MAX_SPEED = 60 local ROT_SPEED = 0.5 -- ~28°/s — deliberate but playable local ONE_TRACK_BONUS = 1.5 -- one-track-only drives at 1.5× the average local COLLISION_RADIUS = 12 local MOVE_EPSILON = 0.02 local BLOCKED_MSG_DURATION = 0.8 local player = { x = 0, z = 0, angle = 0, alive = true, invulnTimer = 0, deathTimer = 0, blockedMessageTimer = 0, blockedJustTriggered = false, _wasBlocked = false, } function Player.init() player.x = World.FIELD_SIZE / 2 player.z = World.FIELD_SIZE / 2 player.angle = 0 player.alive = true player.invulnTimer = 0 -- no respawn-invuln; the scene resets around the player player.deathTimer = 0 player.blockedMessageTimer = 0 player.blockedJustTriggered = false player._wasBlocked = false end function Player.get() return player end function Player.update(dt, obstacles, leftTrack, rightTrack) player.blockedMessageTimer = math.max(0, player.blockedMessageTimer - dt) player._moving = false if not player.alive then player.deathTimer = player.deathTimer + dt return end player.invulnTimer = math.max(0, player.invulnTimer - dt) leftTrack = leftTrack or 0 rightTrack = rightTrack or 0 -- Rotation (opposing tracks): left track forward + right track back => turn right local turn = (leftTrack - rightTrack) * 0.5 player.angle = player.angle + turn * ROT_SPEED * dt -- Forward/reverse (average of tracks), with one-track-only speed bonus local drive = (leftTrack + rightTrack) * 0.5 local leftActive = math.abs(leftTrack) > MOVE_EPSILON local rightActive = math.abs(rightTrack) > MOVE_EPSILON if (leftActive and not rightActive) or (rightActive and not leftActive) then drive = drive * ONE_TRACK_BONUS end local blocked = false if math.abs(drive) > MOVE_EPSILON then player._moving = true local speed = drive * MAX_SPEED -- Forward vector: (sin(angle), cos(angle)); angle=0 → +z local nx = player.x + math.sin(player.angle) * speed * dt local nz = player.z + math.cos(player.angle) * speed * dt nx, nz = World.wrapField(nx, nz) if obstacles then for _, o in ipairs(obstacles) do local dx = World.wrappedDelta(nx, o.x, World.FIELD_SIZE) local dz = World.wrappedDelta(nz, o.z, World.FIELD_SIZE) local dist = math.sqrt(dx*dx + dz*dz) if dist < COLLISION_RADIUS + o.radius then blocked = true break end end end if not blocked then player.x = nx player.z = nz end end -- Fire the "MOTION BLOCKED" message once per sustained block, not every frame if blocked and not player._wasBlocked then player.blockedMessageTimer = BLOCKED_MSG_DURATION player.blockedJustTriggered = true end player._wasBlocked = blocked end function Player.die() player.alive = false player.deathTimer = 0 end function Player.respawn() player.x = World.FIELD_SIZE / 2 player.z = World.FIELD_SIZE / 2 player.alive = true player.invulnTimer = 0 player.deathTimer = 0 end function Player.isMoving() return player._moving == true end return Player