diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a29cbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +CLAUDE.md +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4ff70f --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# OMA-LANDER + +A faithful Lunar Lander arcade recreation for Omarchy Linux, inspired by the 1979 Atari vector cabinet. Built with Love2D — pure Lua, vector line graphics, procedural terrain, procedurally generated audio. + +The lander itself is drawn as an Apollo Lunar Module silhouette: wide octagonal descent stage, angular ascent-stage cabin, splayed A-frame legs with saucer foot pads and surface probes, descent engine bell, docking tunnel, rendezvous radar, and triangular cockpit windows. + +## Install + +```bash +curl -sL https://git.no-signal.uk/nosignal/oma-lander/raw/branch/master/install.sh | bash +``` + +## Uninstall + +```bash +oma-lander-uninstall +``` + +## Controls + +| Input | Action | +|-------|--------| +| **Up / W** | Thrust (main engine) | +| **Left / A** | Rotate counter-clockwise | +| **Right / D** | Rotate clockwise | +| **Space** | Abort burn (auto-level, kills horizontal speed, heavy fuel cost) | +| **Enter** | Confirm / advance menus | +| **Escape** | Back / quit | + +## Gameplay + +Select a mission difficulty, then pilot the LM down through procedurally-generated lunar terrain to a marked landing pad. The camera tracks you and zooms in as you descend — altitude, horizontal and vertical speed, fuel, score and time are on the HUD. + +### Mission difficulties + +| Mission | Gravity | Fuel | Notes | +|---|---|---|---| +| **CADET** | 8 | 1000 | Training — low gravity, extra fuel | +| **PILOT** | 12 | 750 | Standard mission | +| **COMMANDER** | 16 | 600 | Heavier pull, less fuel | +| **ASTRONAUT** | 22 | 500 | Elite — extreme gravity | + +### Landing pads + +Three pads are placed on each generated map, each with a score multiplier. Narrower pads pay more: + +| Label | Width | Multiplier | +|---|---|---| +| 2X | widest | × 2 | +| 3X | medium | × 3 | +| 5X | narrowest | × 5 | + +### Landing results + +Your touchdown is graded by vertical speed, horizontal speed and tilt at contact: + +| Result | Required | Score | Fuel bonus | +|---|---|---|---| +| **A PERFECT LANDING** | on pad, v<8, h<15, tilt<5° | 50 × mult | +50 | +| **GOOD LANDING** | on pad, v<15, h<30, tilt<15° | 25 × mult | +25 | +| **ROUGH LANDING** | on pad, v<25, h<45, tilt<25° | 10 × mult | — | +| **YOU MISSED THE LANDING AREA** | off pad, survivable | 5 | — | +| **CRAFT DESTROYED** | else | 0 | — | + +Run out of fuel and the mission ends. Top-10 high scores are saved locally; enter three initials on a new high score. + +## Omarchy Integration + +- **Theme colours** auto-detected from your active Omarchy Ghostty theme +- **System font** detected from your Waybar config +- **Full-screen** via SUPER+F (Hyprland compositor) + +## Requirements + +- Love2D (`sudo pacman -S love`) +- Omarchy Linux (or any Arch-based distro) + +## Acknowledgements + +Inspired by the 1979 Atari Lunar Lander cabinet. No arcade assets, logos, or trademarked names are reproduced. The LM silhouette is drawn from public reference photographs of the Apollo Lunar Module. + +## License + +MIT diff --git a/game/lander.lua b/game/lander.lua index c5aacbc..829522e 100644 --- a/game/lander.lua +++ b/game/lander.lua @@ -3,43 +3,87 @@ local Palette = require("rendering.palette") local Lander = {} --- 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 +-- Physics tuned to match original Lunar Lander feel. +-- Gravity is set per difficulty via World.gravity. +local THRUST_ACCEL = 36 -- about 3x Pilot-difficulty 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 +local FUEL_RATE = 6 -- fuel per second of thrust +local MAX_SPEED = 120 -- terminal velocity cap --- 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}, +-- Apollo LM-inspired silhouette. +-- Local coords: angle 0 = pointing up, +Y = down. +-- Wide, flat descent stage under a narrower, angular ascent stage; +-- A-frame legs splay out to saucer pads; engine bell protrudes below. + +-- Descent stage: wide, flat, boxy base +local DESCENT = { + {-14, 2}, {14, 2}, {14, 8}, {-14, 8}, } -local LEGS = { - {{-8, 6}, {-14, 16}}, - {{-6, 6}, {-14, 16}}, - {{8, 6}, {14, 16}}, - {{6, 6}, {14, 16}}, +-- Panel lines on descent stage (horizontal divider + vertical bay separators) +local DESCENT_DETAIL = { + {{-14, 5}, {14, 5}}, + {{-5, 2}, {-5, 8}}, + {{5, 2}, {5, 8}}, } -local FEET = { - {{-17, 16}, {-11, 16}}, - {{11, 16}, {17, 16}}, +-- Descent engine bell (hangs below the descent stage) +local ENGINE_BELL = { + {-3, 8}, {-5, 12}, {5, 12}, {3, 8}, } -local WINDOW = { - {{-3, -6}, {3, -6}}, - {{3, -6}, {3, -2}}, - {{3, -2}, {-3, -2}}, - {{-3, -2}, {-3, -6}}, +-- Ascent stage: narrower, angular cabin +local ASCENT = { + {-8, 2}, {-8, -3}, {-7, -7}, {-4, -10}, {4, -10}, {7, -7}, {8, -3}, {8, 2}, } +-- Divider between crew cabin and equipment section +local ASCENT_DETAIL = { + {{-8, -3}, {8, -3}}, +} + +-- RCS thruster quads jutting out at the ascent-stage corners +local RCS_QUADS = { + {{-10, -5}, {-8, -5}, {-8, -3}, {-10, -3}}, + {{10, -5}, {8, -5}, {8, -3}, {10, -3}}, +} + +-- Docking tunnel on top of ascent stage +local DOCKING_TUNNEL = { + {-3, -10}, {-3, -13}, {3, -13}, {3, -10}, +} + +-- Triangular forward cockpit windows +local WINDOWS = { + {{-5, -7}, {-2, -9}, {-2, -6}}, + {{5, -7}, {2, -9}, {2, -6}}, +} + +-- Rendezvous radar / top antenna local ANTENNA = { - {{0, -12}, {0, -18}}, - {{-3, -18}, {3, -18}}, + {{0, -13}, {0, -17}}, + {{-2, -17}, {2, -17}}, +} + +-- Four landing legs as two visible A-frames (main strut + diagonal brace) +-- Legs attach at the outer edges of the descent stage and splay out wide +local LEGS = { + {{-14, 2}, {-22, 16}}, -- left main strut (upper) + {{-14, 8}, {-22, 16}}, -- left secondary strut (lower, forms A-frame) + {{14, 2}, {22, 16}}, -- right main strut + {{14, 8}, {22, 16}}, -- right secondary strut +} + +-- Saucer-shaped foot pads at the A-frame apex +local PAD_POSITIONS = { + {-22, 16}, {22, 16}, +} +local PAD_RADIUS = 3 + +-- Surface-contact probes sticking down from the pads +local PROBES = { + {{-22, 16}, {-22, 20}}, + {{22, 16}, {22, 20}}, } local lander = {} @@ -69,8 +113,8 @@ end function Lander.update(dt) if not lander.alive or lander.landed then return end - -- Gravity (always pulls down) - lander.vy = lander.vy + GRAVITY * dt + -- Gravity (difficulty-driven, always pulls down) + lander.vy = lander.vy + World.gravity * dt -- Rotation if love.keyboard.isDown("left", "a") then @@ -99,8 +143,14 @@ function Lander.update(dt) lander.x = lander.x + lander.vx * dt lander.y = lander.y + lander.vy * dt - -- Clamp X to world bounds - lander.x = math.max(20, math.min(World.WORLD_W - 20, lander.x)) + -- Clamp X to world bounds and kill horizontal momentum against the wall + if lander.x < 20 then + lander.x = 20 + if lander.vx < 0 then lander.vx = 0 end + elseif lander.x > World.WORLD_W - 20 then + lander.x = World.WORLD_W - 20 + if lander.vx > 0 then lander.vx = 0 end + end -- Don't let lander fly off the top if lander.y < 0 then @@ -120,22 +170,18 @@ function Lander.abort() 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() local pts = {} - 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 - 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) + local colPts = { + {-22, 16}, {22, 16}, -- foot pads + {-14, 8}, {0, 8}, {14, 8}, -- descent stage bottom (catches hard tilts) + } + for _, cp in ipairs(colPts) do + local wx, wy = transformPoint(cp[1], cp[2], lander.angle, lander.x, lander.y) table.insert(pts, {x = wx, y = wy}) end return pts @@ -151,6 +197,24 @@ function Lander.land() lander.vy = 0 end +local function drawClosedPolyline(verts, a, cx, cy) + local pts = {} + for _, v in ipairs(verts) do + local wx, wy = transformPoint(v[1], v[2], a, cx, cy) + table.insert(pts, wx) + table.insert(pts, wy) + end + table.insert(pts, pts[1]) + table.insert(pts, pts[2]) + love.graphics.line(pts) +end + +local function drawSegment(seg, a, cx, cy) + local x1, y1 = transformPoint(seg[1][1], seg[1][2], a, cx, cy) + local x2, y2 = transformPoint(seg[2][1], seg[2][2], a, cx, cy) + love.graphics.line(x1, y1, x2, y2) +end + function Lander.draw() if not lander.alive then return end @@ -158,72 +222,94 @@ function Lander.draw() local a = lander.angle local cx, cy = lander.x, lander.y - -- Body outline love.graphics.setColor(p.lander) - love.graphics.setLineWidth(2) - local bodyPts = {} - for _, v in ipairs(BODY) do - local wx, wy = transformPoint(v[1], v[2], a, cx, cy) - table.insert(bodyPts, wx) - table.insert(bodyPts, wy) - end - table.insert(bodyPts, bodyPts[1]) - table.insert(bodyPts, bodyPts[2]) - love.graphics.line(bodyPts) - -- Legs + -- Descent stage outline + love.graphics.setLineWidth(2) + drawClosedPolyline(DESCENT, a, cx, cy) + + -- Descent stage panel detail (thinner) + love.graphics.setLineWidth(1) + for _, seg in ipairs(DESCENT_DETAIL) do + drawSegment(seg, a, cx, cy) + end + + -- Descent engine bell + love.graphics.setLineWidth(1.5) + drawClosedPolyline(ENGINE_BELL, a, cx, cy) + + -- Ascent stage outline + love.graphics.setLineWidth(2) + drawClosedPolyline(ASCENT, a, cx, cy) + + -- Ascent stage cabin/equipment divider + love.graphics.setLineWidth(1) + for _, seg in ipairs(ASCENT_DETAIL) do + drawSegment(seg, a, cx, cy) + end + + -- Docking tunnel on top + love.graphics.setLineWidth(1.5) + drawClosedPolyline(DOCKING_TUNNEL, a, cx, cy) + + -- RCS thruster quads + for _, quad in ipairs(RCS_QUADS) do + drawClosedPolyline(quad, a, cx, cy) + end + + -- Legs (A-frame struts) love.graphics.setLineWidth(1.5) for _, leg in ipairs(LEGS) do - local x1, y1 = transformPoint(leg[1][1], leg[1][2], a, cx, cy) - local x2, y2 = transformPoint(leg[2][1], leg[2][2], a, cx, cy) - love.graphics.line(x1, y1, x2, y2) + drawSegment(leg, a, cx, cy) end - -- Feet - love.graphics.setLineWidth(2) - for _, foot in ipairs(FEET) do - local x1, y1 = transformPoint(foot[1][1], foot[1][2], a, cx, cy) - local x2, y2 = transformPoint(foot[2][1], foot[2][2], a, cx, cy) - love.graphics.line(x1, y1, x2, y2) - end - - -- Window - love.graphics.setColor(p.lander[1], p.lander[2], p.lander[3], 0.5) + -- Surface-contact probes love.graphics.setLineWidth(1) - for _, seg in ipairs(WINDOW) do - local x1, y1 = transformPoint(seg[1][1], seg[1][2], a, cx, cy) - local x2, y2 = transformPoint(seg[2][1], seg[2][2], a, cx, cy) - love.graphics.line(x1, y1, x2, y2) + for _, probe in ipairs(PROBES) do + drawSegment(probe, a, cx, cy) end - -- Antenna + -- Foot pads (saucer circles) + love.graphics.setLineWidth(2) + for _, pad in ipairs(PAD_POSITIONS) do + local wx, wy = transformPoint(pad[1], pad[2], a, cx, cy) + love.graphics.circle("line", wx, wy, PAD_RADIUS) + end + + -- Forward cockpit windows (dimmer) + love.graphics.setColor(p.lander[1], p.lander[2], p.lander[3], 0.6) + love.graphics.setLineWidth(1) + for _, win in ipairs(WINDOWS) do + drawClosedPolyline(win, a, cx, cy) + end + + -- Rendezvous radar / antenna love.graphics.setColor(p.lander) + love.graphics.setLineWidth(1) for _, seg in ipairs(ANTENNA) do - local x1, y1 = transformPoint(seg[1][1], seg[1][2], a, cx, cy) - local x2, y2 = transformPoint(seg[2][1], seg[2][2], a, cx, cy) - love.graphics.line(x1, y1, x2, y2) + drawSegment(seg, a, cx, cy) end - -- Thrust flame (fully transformed) + -- Descent-engine thrust flame (exits the engine bell at y=12) 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 flameLen = 12 + math.random() * 18 + local flameSpread = 2 + math.random() * 2 - 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) + local fx1, fy1 = transformPoint(-flameSpread, 12, a, cx, cy) + local fx2, fy2 = transformPoint(flameSpread, 12, a, cx, cy) + local ftx, fty = transformPoint((math.random()-0.5)*3, 12 + flameLen, a, cx, cy) love.graphics.line(fx1, fy1, ftx, fty) love.graphics.line(fx2, fy2, ftx, fty) -- Inner bright flame love.graphics.setColor(p.bright) - 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) + local flameLen2 = 6 + math.random() * 10 + local fi1x, fi1y = transformPoint(-1, 12, a, cx, cy) + local fi2x, fi2y = transformPoint(1, 12, a, cx, cy) + local ft2x, ft2y = transformPoint((math.random()-0.5)*1.5, 12 + flameLen2, a, cx, cy) love.graphics.line(fi1x, fi1y, ft2x, ft2y) love.graphics.line(fi2x, fi2y, ft2x, ft2y) end diff --git a/game/world.lua b/game/world.lua index f5c9769..73917a8 100644 --- a/game/world.lua +++ b/game/world.lua @@ -12,6 +12,9 @@ local World = { stateTimer = 0, landingResult = "", landingPoints = 0, + gravity = 12, + startFuel = 750, + difficultyName = "PILOT", } function World.resize(w, h) diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..cb58eee --- /dev/null +++ b/icon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6be7be0 --- /dev/null +++ b/install.sh @@ -0,0 +1,142 @@ +#!/bin/bash +set -euo pipefail + +# OMA-LANDER Installer / Uninstaller +# Usage: ./install.sh — install the game +# ./install.sh uninstall — remove the game + +GAME_NAME="oma-lander" +DISPLAY_NAME="OMA-LANDER" +COMMENT="Apollo LM lunar landing arcade with Omarchy theme integration" +REPO_URL="https://git.no-signal.uk/nosignal/oma-lander.git" + +INSTALL_DIR="$HOME/.local/share/$GAME_NAME" +DESKTOP_FILE="$HOME/.local/share/applications/$GAME_NAME.desktop" +ICON_DIR="$HOME/.local/share/icons/hicolor" +UNINSTALL_BIN="$HOME/.local/bin/$GAME_NAME-uninstall" + +# ── UNINSTALL ── +if [ "${1:-}" = "uninstall" ]; then + echo "=== Uninstalling $DISPLAY_NAME ===" + + [ -f "$DESKTOP_FILE" ] && rm "$DESKTOP_FILE" && echo "Removed desktop entry" + + for size in 16 32 48 64 128 256 512; do + icon_path="$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png" + [ -f "$icon_path" ] && rm "$icon_path" + done + [ -f "$ICON_DIR/scalable/apps/$GAME_NAME.svg" ] && rm -f "$ICON_DIR/scalable/apps/$GAME_NAME.svg" + echo "Removed icons" + + [ -d "$INSTALL_DIR" ] && rm -rf "$INSTALL_DIR" && echo "Removed game files" + [ -f "$UNINSTALL_BIN" ] && rm "$UNINSTALL_BIN" && echo "Removed uninstall command" + + command -v gtk-update-icon-cache &>/dev/null && gtk-update-icon-cache -f -t "$ICON_DIR" 2>/dev/null || true + command -v omarchy-restart-walker &>/dev/null && omarchy-restart-walker 2>/dev/null || true + + echo "=== $DISPLAY_NAME uninstalled ===" + exit 0 +fi + +# ── INSTALL ── +echo "=== Installing $DISPLAY_NAME ===" + +# Install dependencies +DEPS=() +command -v love &>/dev/null || DEPS+=(love) +command -v git &>/dev/null || DEPS+=(git) +command -v rsvg-convert &>/dev/null || DEPS+=(librsvg) + +if [ ${#DEPS[@]} -gt 0 ]; then + echo "Installing dependencies: ${DEPS[*]}" + if command -v pacman &>/dev/null; then + sudo pacman -S --noconfirm "${DEPS[@]}" + else + echo "Error: missing ${DEPS[*]} and pacman not found." + echo "Install them manually and re-run this script." + exit 1 + fi +fi + +# Clone or update the game +if [ -d "$INSTALL_DIR/.git" ]; then + echo "Updating existing installation..." + cd "$INSTALL_DIR" + git pull --ff-only +else + [ -d "$INSTALL_DIR" ] && rm -rf "$INSTALL_DIR" + echo "Cloning game repository..." + git clone "$REPO_URL" "$INSTALL_DIR" +fi + +# Install icon +echo "Installing icon..." +ICON_SVG="$INSTALL_DIR/icon.svg" + +if command -v rsvg-convert &>/dev/null; then + for size in 16 32 48 64 128 256 512; do + mkdir -p "$ICON_DIR/${size}x${size}/apps" + rsvg-convert -w "$size" -h "$size" "$ICON_SVG" -o "$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png" + done +elif command -v magick &>/dev/null; then + for size in 16 32 48 64 128 256 512; do + mkdir -p "$ICON_DIR/${size}x${size}/apps" + magick "$ICON_SVG" -resize "${size}x${size}" "$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png" + done +else + echo "No SVG converter found, using SVG icon directly" + mkdir -p "$ICON_DIR/scalable/apps" + cp "$ICON_SVG" "$ICON_DIR/scalable/apps/$GAME_NAME.svg" +fi + +# Create .desktop file +echo "Creating desktop entry..." +mkdir -p "$(dirname "$DESKTOP_FILE")" +cat > "$DESKTOP_FILE" << EOF +[Desktop Entry] +Type=Application +Name=$DISPLAY_NAME +Comment=$COMMENT +Exec=uwsm app -- love $INSTALL_DIR +Icon=$GAME_NAME +Terminal=false +Categories=Game;ArcadeGame; +StartupNotify=true +TryExec=love +EOF + +# Install uninstall command +mkdir -p "$HOME/.local/bin" +cat > "$UNINSTALL_BIN" << 'UNINSTALL' +#!/bin/bash +# Uninstall OMA-LANDER +SCRIPT_URL="https://git.no-signal.uk/nosignal/oma-lander/raw/branch/master/install.sh" +curl -sL "$SCRIPT_URL" | bash -s uninstall 2>/dev/null || bash "$HOME/.local/share/oma-lander/install.sh" uninstall 2>/dev/null || { + rm -f "$HOME/.local/share/applications/oma-lander.desktop" + rm -rf "$HOME/.local/share/oma-lander" + for s in 16 32 48 64 128 256 512; do + rm -f "$HOME/.local/share/icons/hicolor/${s}x${s}/apps/oma-lander.png" + done + rm -f "$HOME/.local/share/icons/hicolor/scalable/apps/oma-lander.svg" + rm -f "$HOME/.local/bin/oma-lander-uninstall" + command -v omarchy-restart-walker &>/dev/null && omarchy-restart-walker 2>/dev/null + echo "OMA-LANDER uninstalled" +} +UNINSTALL +chmod +x "$UNINSTALL_BIN" + +# Update icon cache +command -v gtk-update-icon-cache &>/dev/null && gtk-update-icon-cache -f -t "$ICON_DIR" 2>/dev/null || true + +# Restart walker +if command -v omarchy-restart-walker &>/dev/null; then + echo "Refreshing app launcher..." + omarchy-restart-walker 2>/dev/null || true +fi + +echo "" +echo "=== $DISPLAY_NAME installed ===" +echo "" +echo " Launch: search '$DISPLAY_NAME' in app launcher or run: love $INSTALL_DIR" +echo " Uninstall: $GAME_NAME-uninstall" +echo "" diff --git a/main.lua b/main.lua index 6a92add..843289c 100644 --- a/main.lua +++ b/main.lua @@ -14,10 +14,25 @@ local thrustSoundTimer = 0 local fuelWarnTimer = 0 local STATE_PAUSE = 3.0 +local DIFFICULTIES = { + {name = "CADET", gravity = 8, fuel = 1000, description = "TRAINING - LOW GRAVITY"}, + {name = "PILOT", gravity = 12, fuel = 750, description = "STANDARD MISSION"}, + {name = "COMMANDER", gravity = 16, fuel = 600, description = "EXPERIENCED - HEAVIER PULL"}, + {name = "ASTRONAUT", gravity = 22, fuel = 500, description = "ELITE - EXTREME GRAVITY"}, +} +local difficultyIndex = 2 + +local function applyDifficulty(d) + World.gravity = d.gravity + World.startFuel = d.fuel + World.difficultyName = d.name +end + local function startGame() + applyDifficulty(DIFFICULTIES[difficultyIndex]) World.state = "playing" World.score = 0 - World.fuel = 750 + World.fuel = World.startFuel World.time = 0 World.stateTimer = 0 @@ -49,30 +64,47 @@ local function checkLanding() local hspeed = math.abs(l.vx) local tilt = math.abs(l.angle) - if pad and vspeed < 15 and hspeed < 30 and tilt < math.rad(15) then - -- Good landing + if pad and vspeed < 8 and hspeed < 15 and tilt < math.rad(5) then + -- A perfect landing — tightest tolerances local pts = 50 * pad.mult World.addScore(pts) World.fuel = World.fuel + 50 - World.landingResult = "GOOD LANDING! " .. pad.label .. " = " .. pts .. " PTS" + World.landingResult = "CONGRATULATIONS — A PERFECT LANDING " .. pts .. " PTS" + World.landingPoints = pts + World.state = "landed" + Lander.land() + Sounds.play("land_good") + elseif pad and vspeed < 15 and hspeed < 30 and tilt < math.rad(15) then + -- Good landing + local pts = 25 * pad.mult + World.addScore(pts) + World.fuel = World.fuel + 25 + World.landingResult = "GOOD LANDING " .. pts .. " PTS" World.landingPoints = pts World.state = "landed" Lander.land() Sounds.play("land_good") elseif pad and vspeed < 25 and hspeed < 45 and tilt < math.rad(25) then - -- Hard landing - local pts = 15 * pad.mult + -- Rough but survivable on-pad landing + local pts = 10 * pad.mult World.addScore(pts) - World.landingResult = "HARD LANDING " .. pts .. " PTS" + World.landingResult = "ROUGH LANDING " .. pts .. " PTS" World.landingPoints = pts World.state = "landed" Lander.land() Sounds.play("land_hard") - else - -- Crash + elseif (not pad) and vspeed < 20 and hspeed < 40 and tilt < math.rad(20) then + -- Missed the pad but touched down intact World.addScore(5) - World.landingResult = "CRASH! 5 PTS" + World.landingResult = "YOU MISSED THE LANDING AREA 5 PTS" World.landingPoints = 5 + World.state = "landed" + Lander.land() + Sounds.play("land_hard") + else + -- Destroyed + World.landingResult = "CRAFT DESTROYED" + World.landingPoints = 0 World.state = "crashed" Particles.spawnCrash(l.x, l.y, l.vx, l.vy) Lander.die() @@ -111,7 +143,7 @@ local function drawTitleScreen() local pulse = 0.3 + math.sin(t * 3) * 0.3 love.graphics.setFont(Fonts.medium) love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse + 0.2) - love.graphics.printf("PRESS ENTER TO START", 0, sh * 0.52, sw, "center") + love.graphics.printf("PRESS ENTER TO SELECT MISSION", 0, sh * 0.52, sw, "center") -- High scores local allScores = HighScores.getScores() @@ -120,6 +152,51 @@ local function drawTitleScreen() end end +local function drawDifficultyScreen() + local p = Palette.get() + local sw, sh = World.screenW, World.screenH + local t = love.timer.getTime() + + love.graphics.setColor(p.bg) + love.graphics.rectangle("fill", 0, 0, sw, sh) + Stars.draw(sw, sh) + + love.graphics.setFont(Fonts.large) + love.graphics.setColor(p.bright) + love.graphics.printf("SELECT MISSION", 0, sh * 0.12, sw, "center") + + love.graphics.setFont(Fonts.medium) + local lineH = Fonts.medium:getHeight() + 12 + local menuTop = sh * 0.30 + + for i, d in ipairs(DIFFICULTIES) do + local y = menuTop + (i - 1) * lineH + local selected = (i == difficultyIndex) + if selected then + local pulse = 0.7 + math.sin(t * 4) * 0.3 + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse) + love.graphics.printf("> " .. d.name .. " <", 0, y, sw, "center") + else + love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.5) + love.graphics.printf(d.name, 0, y, sw, "center") + end + end + + -- Description of selected mission + love.graphics.setFont(Fonts.small) + love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.7) + local d = DIFFICULTIES[difficultyIndex] + local descY = menuTop + #DIFFICULTIES * lineH + 20 + love.graphics.printf(d.description, 0, descY, sw, "center") + love.graphics.printf(string.format("GRAVITY %d FUEL %d", d.gravity, d.fuel), + 0, descY + Fonts.small:getHeight() + 4, sw, "center") + + -- Instructions + love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.4) + love.graphics.printf("UP / DOWN: CHANGE MISSION ENTER: BEGIN ESC: BACK", + 0, sh - Fonts.small:getHeight() - 12, sw, "center") +end + function love.load() love.mouse.setVisible(false) love.graphics.setBackgroundColor(0, 0, 0) @@ -142,7 +219,7 @@ end function love.update(dt) World.ensureScale() - if World.state == "title" then + if World.state == "title" or World.state == "difficulty_select" then return end @@ -160,10 +237,10 @@ function love.update(dt) thrustSoundTimer = 0 Sounds.play("thrust") end - -- Thrust particles — spawn at nozzle transformed by lander angle + -- Thrust particles — spawn at engine-bell exit transformed by lander angle local sa, ca = math.sin(l.angle), math.cos(l.angle) - local fx = l.x - sa * 8 - local fy = l.y + ca * 8 + local fx = l.x - sa * 12 + local fy = l.y + ca * 12 Particles.spawnThrust(fx, fy, l.angle) else thrustSoundTimer = 0.15 @@ -213,6 +290,11 @@ function love.draw() return end + if World.state == "difficulty_select" then + drawDifficultyScreen() + return + end + if World.state == "high_score_entry" then love.graphics.setColor(p.bg) love.graphics.rectangle("fill", 0, 0, sw, sh) @@ -290,11 +372,24 @@ end function love.keypressed(key) if World.state == "title" then - if key == "return" then startGame() end + if key == "return" then World.state = "difficulty_select" end if key == "escape" then love.event.quit() end return end + if World.state == "difficulty_select" then + if key == "up" or key == "w" then + difficultyIndex = ((difficultyIndex - 2) % #DIFFICULTIES) + 1 + elseif key == "down" or key == "s" then + difficultyIndex = (difficultyIndex % #DIFFICULTIES) + 1 + elseif key == "return" then + startGame() + elseif key == "escape" then + World.state = "title" + end + return + end + if World.state == "high_score_entry" then local result = HighScores.keypressedEntry(key) if result == "done" then