diff --git a/conf.lua b/conf.lua index f25e230..3b8658e 100644 --- a/conf.lua +++ b/conf.lua @@ -1,5 +1,5 @@ function love.conf(t) - t.window.title = "LUNAR LANDER" + t.window.title = "OMA-LANDER" t.window.width = 1024 t.window.height = 768 t.window.resizable = true diff --git a/game/camera.lua b/game/camera.lua index a135888..4ae18b4 100644 --- a/game/camera.lua +++ b/game/camera.lua @@ -2,30 +2,40 @@ local World = require("game.world") local Camera = { x = 2000, - y = 400, - zoom = 0.5, + y = 900, + zoom = 0.35, } -local BASE_ALT = 1200 -local MAX_ZOOM = 4.0 -local MIN_ZOOM = 0.35 +local MAX_ZOOM = 3.5 +local MIN_ZOOM = 0.3 +local ZOOM_SPEED = 2.0 function Camera.update(lander, terrain, dt) - -- Track lander position + -- Track lander X Camera.x = lander.x - Camera.y = lander.y - -- Compute altitude + -- Get ground height below lander local groundY = terrain.getHeightAt(lander.x) local altitude = groundY - lander.y - altitude = math.max(altitude, 10) - -- Zoom based on altitude - local targetZoom = BASE_ALT / altitude + -- Camera Y: always frame BOTH the lander and the terrain + -- Centre the view between the lander and the ground, biased toward showing terrain + local midY = (lander.y + groundY) / 2 + -- Bias: keep terrain in the lower third even at high altitude + local targetY = lander.y + altitude * 0.45 + + Camera.y = Camera.y + (targetY - Camera.y) * math.min(1, dt * 3) + + -- Zoom: fit the distance from lander to ground within the viewport + -- We want the full altitude span to fit in about 70% of screen height + local sw, sh = World.screenW, World.screenH + local effectiveScale = World.baseScale + local neededHeight = math.max(altitude + 100, 200) -- minimum view range + local viewportFraction = 0.7 + local targetZoom = (sh * viewportFraction) / (neededHeight * effectiveScale) targetZoom = math.max(MIN_ZOOM, math.min(MAX_ZOOM, targetZoom)) - -- Smooth lerp - Camera.zoom = Camera.zoom + (targetZoom - Camera.zoom) * math.min(1, dt * 2.5) + Camera.zoom = Camera.zoom + (targetZoom - Camera.zoom) * math.min(1, dt * ZOOM_SPEED) end function Camera.getAltitude(landerY, terrain, landerX) @@ -61,7 +71,7 @@ end function Camera.reset() Camera.x = 2000 - Camera.y = 400 + Camera.y = 900 Camera.zoom = MIN_ZOOM end diff --git a/game/lander.lua b/game/lander.lua index ead0f11..c5aacbc 100644 --- a/game/lander.lua +++ b/game/lander.lua @@ -3,12 +3,16 @@ local Palette = require("rendering.palette") local Lander = {} -local GRAVITY = 25 -local THRUST_ACCEL = 60 -local ROT_SPEED = 2.1 -- ~120 deg/sec in radians -local FUEL_RATE = 12 -- fuel per second at full thrust +-- Physics tuned to match original Lunar Lander feel: +-- Gentle lunar gravity, thrust comfortably overcomes it, +-- deliberate rotation, fuel lasts long enough to learn +local GRAVITY = 12 -- gentle lunar pull (original was ~1/6 earth) +local THRUST_ACCEL = 36 -- about 3x gravity — can hover and climb +local ROT_SPEED = 1.4 -- ~80 deg/sec — deliberate, not twitchy +local FUEL_RATE = 6 -- fuel per second — 750 gives ~125 sec of thrust +local MAX_SPEED = 120 -- terminal velocity cap --- Lander shape (local coords, 0 = pointing up, Y+ down) +-- Lander shape (local coords, angle 0 = pointing up, Y+ is down in world) local BODY = { {-8, -8}, {-4, -12}, {4, -12}, {8, -8}, {10, 0}, {8, 6}, {-8, 6}, {-10, 0}, @@ -41,11 +45,11 @@ local ANTENNA = { local lander = {} function Lander.init() - lander.x = World.WORLD_W / 2 + (math.random() - 0.5) * 1000 + lander.x = World.WORLD_W / 2 + (math.random() - 0.5) * 800 lander.y = 200 - lander.vx = 20 + math.random() * 20 - lander.vy = 5 + math.random() * 10 - lander.angle = 0 -- 0 = pointing up + lander.vx = 5 + math.random() * 8 -- gentle initial drift + lander.vy = 2 + math.random() * 3 -- slight downward + lander.angle = 0 lander.alive = true lander.landed = false lander.thrusting = false @@ -65,7 +69,7 @@ end function Lander.update(dt) if not lander.alive or lander.landed then return end - -- Gravity + -- Gravity (always pulls down) lander.vy = lander.vy + GRAVITY * dt -- Rotation @@ -76,46 +80,60 @@ function Lander.update(dt) lander.angle = lander.angle + ROT_SPEED * dt end - -- Thrust + -- Thrust (fires out the bottom of the lander, pushing opposite) lander.thrusting = love.keyboard.isDown("up", "w") and World.fuel > 0 if lander.thrusting then - -- Thrust in the direction the lander is pointing (up from lander's perspective) - lander.vx = lander.vx - math.sin(lander.angle) * THRUST_ACCEL * dt + lander.vx = lander.vx + math.sin(lander.angle) * THRUST_ACCEL * dt lander.vy = lander.vy - math.cos(lander.angle) * THRUST_ACCEL * dt World.fuel = math.max(0, World.fuel - FUEL_RATE * dt) end + -- Cap speed so it doesn't get out of control + local speed = math.sqrt(lander.vx * lander.vx + lander.vy * lander.vy) + if speed > MAX_SPEED then + lander.vx = lander.vx / speed * MAX_SPEED + lander.vy = lander.vy / speed * MAX_SPEED + end + -- Move lander.x = lander.x + lander.vx * dt lander.y = lander.y + lander.vy * dt - -- Clamp X + -- Clamp X to world bounds lander.x = math.max(20, math.min(World.WORLD_W - 20, lander.x)) + + -- Don't let lander fly off the top + if lander.y < 0 then + lander.y = 0 + lander.vy = math.max(0, lander.vy) + end end function Lander.abort() if not lander.alive or lander.landed then return end if World.fuel < 10 then return end - -- Auto-level: snap angle toward 0 - lander.angle = lander.angle * 0.3 - -- Full thrust burst - lander.vy = lander.vy - THRUST_ACCEL * 0.5 - -- Costs a chunk of fuel - World.fuel = math.max(0, World.fuel - 50) + -- Auto-level: snap angle to upright + lander.angle = lander.angle * 0.1 + -- Strong upward thrust burst — halve downward velocity and push up + if lander.vy > 0 then + lander.vy = lander.vy * 0.3 + end + lander.vy = lander.vy - THRUST_ACCEL * 0.6 + -- Kill most horizontal speed + lander.vx = lander.vx * 0.3 + -- Heavy fuel cost + World.fuel = math.max(0, World.fuel - 60) end function Lander.getCollisionPoints() - -- Return transformed foot positions for terrain collision local pts = {} - -- Foot endpoints local footPts = {{-17, 16}, {-11, 16}, {11, 16}, {17, 16}} for _, fp in ipairs(footPts) do local wx, wy = transformPoint(fp[1], fp[2], lander.angle, lander.x, lander.y) table.insert(pts, {x = wx, y = wy}) end - -- Body bottom - local bodyBottom = {{-8, 6}, {8, 6}} + local bodyBottom = {{-8, 6}, {8, 6}, {0, 6}} for _, bp in ipairs(bodyBottom) do local wx, wy = transformPoint(bp[1], bp[2], lander.angle, lander.x, lander.y) table.insert(pts, {x = wx, y = wy}) @@ -186,25 +204,28 @@ function Lander.draw() love.graphics.line(x1, y1, x2, y2) end - -- Thrust flame + -- Thrust flame (fully transformed) if lander.thrusting then love.graphics.setColor(p.thrust) love.graphics.setLineWidth(2) local flameLen = 10 + math.random() * 15 local flameSpread = 3 + math.random() * 3 - local fx1, fy1 = transformPoint(-flameSpread, 6, a, cx, cy) - local fx2, fy2 = transformPoint(flameSpread, 6, a, cx, cy) - local ftx, fty = transformPoint((math.random()-0.5)*3, 6 + flameLen, a, cx, cy) + local fx1, fy1 = transformPoint(-flameSpread, 8, a, cx, cy) + local fx2, fy2 = transformPoint(flameSpread, 8, a, cx, cy) + local ftx, fty = transformPoint((math.random()-0.5)*3, 8 + flameLen, a, cx, cy) love.graphics.line(fx1, fy1, ftx, fty) love.graphics.line(fx2, fy2, ftx, fty) - -- Inner flame - local flameLen2 = 5 + math.random() * 8 - local ft2x, ft2y = transformPoint((math.random()-0.5)*2, 6 + flameLen2, a, cx, cy) + -- Inner bright flame love.graphics.setColor(p.bright) - love.graphics.line(cx, cy + 3, ft2x, ft2y) + local flameLen2 = 5 + math.random() * 8 + local fi1x, fi1y = transformPoint(-1.5, 8, a, cx, cy) + local fi2x, fi2y = transformPoint(1.5, 8, a, cx, cy) + local ft2x, ft2y = transformPoint((math.random()-0.5)*1.5, 8 + flameLen2, a, cx, cy) + love.graphics.line(fi1x, fi1y, ft2x, ft2y) + love.graphics.line(fi2x, fi2y, ft2x, ft2y) end end diff --git a/game/terrain.lua b/game/terrain.lua index 0e290de..ff195d6 100644 --- a/game/terrain.lua +++ b/game/terrain.lua @@ -12,90 +12,66 @@ function Terrain.generate() local W = World.WORLD_W local baseline = 1600 - local step = 30 - -- Place landing pads first + -- Place 3 landing pads at fixed zones to avoid overlap issues local padDefs = { - {width = 120, mult = 2, label = "2X"}, - {width = 80, mult = 3, label = "3X"}, - {width = 50, mult = 5, label = "5X"}, + {cx = W * 0.2, width = 120, mult = 2, label = "2X"}, + {cx = W * 0.55, width = 80, mult = 3, label = "3X"}, + {cx = W * 0.8, width = 50, mult = 5, label = "5X"}, } - -- Distribute pads across the terrain - local padPositions = {} - local usedZones = {} for _, def in ipairs(padDefs) do - local attempts = 0 - local px - repeat - px = 400 + math.random() * (W - 800) - attempts = attempts + 1 - local ok = true - for _, used in ipairs(usedZones) do - if math.abs(px - used) < 400 then ok = false; break end - end - if ok then break end - until attempts > 50 - local py = baseline + (math.random() - 0.5) * 200 - table.insert(padPositions, {x = px, y = py, width = def.width, mult = def.mult, label = def.label}) - table.insert(usedZones, px) table.insert(pads, { - x1 = px - def.width / 2, - x2 = px + def.width / 2, + x1 = def.cx - def.width / 2, + x2 = def.cx + def.width / 2, y = py, mult = def.mult, label = def.label, }) end - -- Sort pads by X for terrain generation table.sort(pads, function(a, b) return a.x1 < b.x1 end) - -- Generate terrain points left to right + -- Generate terrain left to right, inserting pads as flat segments local x = 0 - local y = baseline + (math.random() - 0.5) * 100 - table.insert(points, {x = x, y = y}) + local y = baseline + (math.random() - 0.5) * 80 + table.insert(points, {x = 0, y = y}) + + local padIdx = 1 while x < W do - -- Check if we're approaching a pad - local onPad = false - local currentPad = nil - for _, pad in ipairs(pads) do - if x >= pad.x1 - step and x <= pad.x2 + step then - onPad = true - currentPad = pad - break - end - end - - if onPad and currentPad then - -- Transition to pad level - if x < currentPad.x1 then - -- Approach: slope down/up to pad - table.insert(points, {x = currentPad.x1 - 5, y = y}) - table.insert(points, {x = currentPad.x1, y = currentPad.y}) - x = currentPad.x1 - end + -- Check if next pad is coming up + if padIdx <= #pads and x >= pads[padIdx].x1 - 40 then + local pad = pads[padIdx] + -- Slope to pad start + table.insert(points, {x = pad.x1 - 5, y = y}) + table.insert(points, {x = pad.x1, y = pad.y}) -- Flat pad - table.insert(points, {x = currentPad.x2, y = currentPad.y}) - x = currentPad.x2 - y = currentPad.y - -- Resume jagged after pad - x = x + step * 0.5 - y = y + (math.random() - 0.5) * 60 - table.insert(points, {x = x, y = y}) + table.insert(points, {x = pad.x2, y = pad.y}) + x = pad.x2 + 1 + y = pad.y + padIdx = padIdx + 1 + -- Resume jagged + local jx = x + 20 + math.random() * 30 + y = y + (math.random() - 0.5) * 80 + y = math.max(baseline - 250, math.min(baseline + 250, y)) + table.insert(points, {x = jx, y = y}) + x = jx else - x = x + step + math.random() * step - -- Jagged variation - y = y + (math.random() - 0.5) * 120 + -- Normal jagged terrain + local step = 25 + math.random() * 35 + x = x + step + y = y + (math.random() - 0.5) * 100 y = math.max(baseline - 250, math.min(baseline + 250, y)) table.insert(points, {x = x, y = y}) end end - -- Ensure last point reaches edge - table.insert(points, {x = W, y = points[#points].y}) + -- Close at right edge + if points[#points].x < W then + table.insert(points, {x = W, y = points[#points].y}) + end end function Terrain.getPoints() @@ -107,7 +83,6 @@ function Terrain.getPads() end function Terrain.getHeightAt(wx) - -- Find terrain height at world X by interpolating between points if #points < 2 then return 1600 end if wx <= points[1].x then return points[1].y end if wx >= points[#points].x then return points[#points].y end @@ -133,13 +108,13 @@ end function Terrain.draw(visMinX, visMaxX) local p = Palette.get() - -- Draw terrain surface + -- Terrain surface love.graphics.setColor(p.terrain) love.graphics.setLineWidth(2) local pts = {} for _, pt in ipairs(points) do - if pt.x >= visMinX - 100 and pt.x <= visMaxX + 100 then + if pt.x >= visMinX - 200 and pt.x <= visMaxX + 200 then table.insert(pts, pt.x) table.insert(pts, pt.y) end @@ -148,17 +123,24 @@ function Terrain.draw(visMinX, visMaxX) love.graphics.line(pts) end - -- Draw landing pads (brighter, with labels) - love.graphics.setColor(p.pad) - love.graphics.setLineWidth(3) + -- Landing pads (brighter) for _, pad in ipairs(pads) do if pad.x2 >= visMinX and pad.x1 <= visMaxX then + love.graphics.setColor(p.pad) + love.graphics.setLineWidth(3) love.graphics.line(pad.x1, pad.y, pad.x2, pad.y) - -- Label below pad - love.graphics.setColor(p.pad[1], p.pad[2], p.pad[3], 0.7) - local labelX = (pad.x1 + pad.x2) / 2 - love.graphics.print(pad.label, labelX - 8, pad.y + 5) + -- Multiplier label — constant screen size regardless of zoom + love.graphics.setColor(p.pad[1], p.pad[2], p.pad[3], 0.8) + local cx = (pad.x1 + pad.x2) / 2 + local Camera = require("game.camera") + local World = require("game.world") + local invZoom = 1 / (World.baseScale * Camera.getZoom()) + love.graphics.push() + love.graphics.translate(cx, pad.y + 15 * invZoom) + love.graphics.scale(invZoom * 0.8, invZoom * 0.8) + love.graphics.printf(pad.label, -50, 0, 100, "center") + love.graphics.pop() end end end diff --git a/main.lua b/main.lua index 00edd59..12de54c 100644 --- a/main.lua +++ b/main.lua @@ -99,7 +99,7 @@ local function drawTitleScreen() local titleY = sh * 0.15 love.graphics.setFont(Fonts.large) love.graphics.setColor(p.bright) - love.graphics.printf("LUNAR LANDER", 0, titleY, sw, "center") + love.graphics.printf("OMA-LANDER", 0, titleY, sw, "center") -- Controls love.graphics.setFont(Fonts.small)