"""
VOYAGER — an overhaul of the original "Trajectory" sandbox
----------------------------------------------------------------------------
Flight model (v2):
  * LEFT / RIGHT  : rotate the ship
  * UP            : main engine (strong thrust along the nose)
  * DOWN          : reverse/retro burn (weaker, opposite the nose)
  * W A S D       : RCS — fixed, world-aligned maneuvering puffs for fine
                     positioning, independent of which way you're facing
  * Z / X         : zoom the camera out / in
  * R             : restart after a crash, ENTER: start from the title

Other overhaul notes:
  * Resolution scaled 8x (160x120 -> 1280x960) via a single SCALE constant.
  * Five planet types (rocky / ice / gas giant / star / black hole).
  * Danger-colored trajectory projection (green/yellow/red).
  * Planets are generated out to the radius visible at MAX ZOOM-OUT, cached
    per grid cell, so nothing pops in or out of existence as you zoom or
    fly — what you'd see (or feel via the trajectory line) is always
    already "real".
  * Fuel + hull gauges, orbit trail, screen shake, particle FX, an on-screen
    minimap that's actually readable, and noise-based (not tonal) RCS/main
    engine sound that only plays while the key is held.
"""

import pyxel
import random
import math

# ---------------------------------------------------------------------------
# Resolution & world constants
# ---------------------------------------------------------------------------
SCALE = 8                                   # <- resolution multiplier
WORLD_W, WORLD_H = 160, 120                 # original logical playfield
WIDTH, HEIGHT = WORLD_W * SCALE, WORLD_H * SCALE
CX, CY = WIDTH // 2, HEIGHT // 2            # ship is always screen-centered

# Physics stays in "world units" (unscaled) so the original feel/tuning is
# preserved; only rendering positions/sizes are multiplied by SCALE.
CELL_SIZE = 400
G = 0.01

ROT_SPEED = 0.055
ACCEL_MAIN = 0.16
ACCEL_REVERSE = 0.08
ACCEL_RCS = 0.02
FUEL_COST_MAIN = 0.16
FUEL_COST_REVERSE = 0.8
FUEL_COST_RCS = 0.02

MAX_FUEL = 100.0
MAX_HULL = 100.0

MIN_ZOOM, MAX_ZOOM = 0.2, 2.0

# Planets are generated for every grid cell within CELL_RADIUS of the ship,
# where CELL_RADIUS is sized to cover what's visible at MIN_ZOOM (maximum
# zoom-out). That means the planet field never changes based on your
# *current* zoom or flight path -- nothing spawns or despawns as you zoom
# or fly, on screen or on the trajectory line.
_half_view_at_min_zoom = (max(WIDTH, HEIGHT) / 2) / MIN_ZOOM
CELL_RADIUS = int(math.ceil(_half_view_at_min_zoom / CELL_SIZE)) + 1

STATE_TITLE, STATE_PLAY, STATE_OVER = range(3)

# name, colors (mid, core, rim), mass multiplier, is_deadly, has_rings
PLANET_TYPES = [
    ("rocky",      (2, 4, 13),   1.0, False, False),
    ("ice",        (6, 12, 7),   0.8, False, False),
    ("gas giant",  (9, 10, 14),  1.6, False, True),
    ("star",       (10, 9, 8),   3.0, False, False),
    ("black hole", (0, 1, 5),    9.0, True,  False),
]


def text_big(x, y, s, col, scale=2):
    """pyxel's font is a fixed 4x6px -- stamp it a few times with sub-pixel
    offsets so it reads clearly on the 8x canvas."""
    for ox in range(scale):
        for oy in range(scale):
            pyxel.text(x + ox, y + oy, s, col)


class Ship:
    def __init__(self):
        self.px, self.py = 0.0, 0.0
        self.vx, self.vy = 0.0, 0.0
        self.angle = -math.pi / 2  # facing "up" initially
        self.fuel = MAX_FUEL
        self.hull = MAX_HULL
        self.trail = []  # list of [world_x, world_y, life]


class Particle:
    __slots__ = ("wx", "wy", "vx", "vy", "life", "col")

    def __init__(self, wx, wy, vx, vy, life, col):
        self.wx, self.wy, self.vx, self.vy = wx, wy, vx, vy
        self.life, self.col = life, col


class App:
    def __init__(self):
        pyxel.init(WIDTH, HEIGHT, title="Voyager", fps=60)
        pyxel.mouse(False)
        self._init_sounds()
        self.stars = [
            (random.randint(0, WIDTH - 1), random.randint(0, HEIGHT - 1), random.random())
            for _ in range(220)
        ]
        self.planet_cache = {}
        self.state = STATE_TITLE
        self.zoom = 1.0
        self.shake = 0.0
        self.new_game()
        pyxel.run(self.update, self.draw)

    # ------------------------------------------------------------------ #
    def _init_sounds(self):
        # Channel 0: RCS puffs -- pure noise, short/tight so it reads as
        # bursts rather than a tone, looped only while a WASD key is held.
        pyxel.sounds[0].set(notes="c1", tones="n", volumes="3", effects="n", speed=6)
        # Channel 1: main engine -- pure noise, lower/louder, looped only
        # while UP/DOWN is held.
        pyxel.sounds[4].set(notes="c0", tones="n", volumes="5", effects="n", speed=6)
        # One-shots on a shared channel.
        pyxel.sounds[1].set("f1c1f0c0", "n", "7654", "f", 6)       # crash boom
        pyxel.sounds[2].set("c3e3g3c4e4", "s", "77654", "n", 12)   # refuel chime
        pyxel.sounds[3].set("c2", "n", "4", "f", 30)               # black hole doom

    def new_game(self):
        self.ship = Ship()
        self.particles = []
        self.zoom = 1.0
        self.shake = 0.0
        self.state = STATE_PLAY
        pyxel.stop(0)
        pyxel.stop(1)

    # ------------------------------------------------------------------ #
    def _get_cell_planet(self, cx, cy):
        """Deterministic, cached per-cell planet lookup. Using a local RNG
        (not the global one) and a cache means this is cheap to call for a
        wide radius every frame."""
        key = (cx, cy)
        cached = self.planet_cache.get(key, 0)
        if cached != 0:
            return cached
        rng = random.Random(hash((cx, cy, 7)))
        planet = None
        if rng.random() < 0.5:
            x = cx * CELL_SIZE + rng.randint(40, CELL_SIZE - 40)
            y = cy * CELL_SIZE + rng.randint(40, CELL_SIZE - 40)
            rad = rng.randint(10, 30)
            name, cols, mass_mult, deadly, rings = rng.choices(
                PLANET_TYPES, weights=[40, 25, 20, 10, 5]
            )[0]
            planet = {
                "x": x, "y": y, "rad": rad,
                "m": (rad ** 3) * mass_mult,
                "cols": cols, "deadly": deadly, "rings": rings, "name": name,
            }
        self.planet_cache[key] = planet
        return planet

    def get_planets(self, px, py):
        cx_b, cy_b = int(px // CELL_SIZE), int(py // CELL_SIZE)
        planets = []
        for cx in range(cx_b - CELL_RADIUS, cx_b + CELL_RADIUS + 1):
            for cy in range(cy_b - CELL_RADIUS, cy_b + CELL_RADIUS + 1):
                p = self._get_cell_planet(cx, cy)
                if p:
                    planets.append(p)
        return planets

    # ------------------------------------------------------------------ #
    def update(self):
        if self.state == STATE_TITLE:
            if pyxel.btnp(pyxel.KEY_RETURN) or pyxel.btnp(pyxel.KEY_SPACE):
                self.new_game()
            return
        if self.state == STATE_OVER:
            if pyxel.btnp(pyxel.KEY_R):
                self.new_game()
            return
        self._update_play()

    def _update_play(self):
        ship = self.ship

        if pyxel.btn(pyxel.KEY_Z):
            self.zoom = min(self.zoom + 0.02, MAX_ZOOM)
        if pyxel.btn(pyxel.KEY_X):
            self.zoom = max(self.zoom - 0.02, MIN_ZOOM)

        # ---- rotation (free -- reaction wheels, no propellant) ----
        if pyxel.btn(pyxel.KEY_LEFT):
            ship.angle -= ROT_SPEED
        if pyxel.btn(pyxel.KEY_RIGHT):
            ship.angle += ROT_SPEED

        fx, fy = math.cos(ship.angle), math.sin(ship.angle)

        # ---- main engine (UP) / retro burn (DOWN) ----
        main_active = False
        if ship.fuel > 0:
            if pyxel.btn(pyxel.KEY_UP):
                ship.vx += fx * ACCEL_MAIN
                ship.vy += fy * ACCEL_MAIN
                ship.fuel = max(0.0, ship.fuel - FUEL_COST_MAIN)
                main_active = True
                self._spawn_engine_particles(-fx, -fy, big=True)
            elif pyxel.btn(pyxel.KEY_DOWN):
                ship.vx -= fx * ACCEL_REVERSE
                ship.vy -= fy * ACCEL_REVERSE
                ship.fuel = max(0.0, ship.fuel - FUEL_COST_REVERSE)
                main_active = True
                self._spawn_engine_particles(fx, fy, big=False)

        if main_active:
            if pyxel.play_pos(1) is None:
                pyxel.play(1, 4, loop=True)
        else:
            pyxel.stop(1)

        # ---- RCS (WASD, world-aligned, fine control) ----
        rcs_active = False
        if ship.fuel > 0:
            if pyxel.btn(pyxel.KEY_W):
                ship.vy -= ACCEL_RCS; rcs_active = True
                self._spawn_engine_particles(0, 1, big=False)
            if pyxel.btn(pyxel.KEY_S):
                ship.vy += ACCEL_RCS; rcs_active = True
                self._spawn_engine_particles(0, -1, big=False)
            if pyxel.btn(pyxel.KEY_A):
                ship.vx -= ACCEL_RCS; rcs_active = True
                self._spawn_engine_particles(1, 0, big=False)
            if pyxel.btn(pyxel.KEY_D):
                ship.vx += ACCEL_RCS; rcs_active = True
                self._spawn_engine_particles(-1, 0, big=False)
            if rcs_active:
                ship.fuel = max(0.0, ship.fuel - FUEL_COST_RCS)

        if rcs_active:
            if pyxel.play_pos(0) is None:
                pyxel.play(0, 0, loop=True)
        else:
            pyxel.stop(0)

        # ---- physics ----
        ship.px += ship.vx
        ship.py += ship.vy
        nearby = self.get_planets(ship.px, ship.py)
        near_star = False
        for p in nearby:
            dx, dy = p["x"] - ship.px, p["y"] - ship.py
            dist_sq = max(dx * dx + dy * dy, 150)
            dist = math.sqrt(dist_sq)

            if dist < 300:
                force = (G * p["m"]) / dist_sq
                ship.vx += force * (dx / dist)
                ship.vy += force * (dy / dist)

            if p["name"] == "star" and dist < p["rad"] * 5:
                near_star = True

            if p["deadly"] and dist < p["rad"] * 2.2:
                self._crash(boom_at=(p["x"], p["y"]), sound=3)
                return

            if dist < p["rad"] + 2:
                nx, ny = dx / dist, dy / dist
                ship.px -= nx * (p["rad"] + 2 - dist)
                dot = ship.vx * nx + ship.vy * ny
                impact_speed = abs(dot)
                if dot > 0:
                    ship.vx -= 1.3 * dot * nx
                    ship.vy -= 1.3 * dot * ny
                if impact_speed > 1.2:
                    ship.hull -= min(60, (impact_speed - 1.2) * 30)
                    self.shake = min(14, impact_speed * 4)
                    pyxel.play(2, 1)
                    for _ in range(14):
                        ang = random.uniform(0, math.tau)
                        spd = random.uniform(0.5, 3.0)
                        self.particles.append(Particle(
                            ship.px, ship.py,
                            math.cos(ang) * spd, math.sin(ang) * spd,
                            random.randint(10, 20), random.choice([8, 9, 10]),
                        ))
                    if ship.hull <= 0:
                        self._crash(boom_at=(ship.px, ship.py), sound=1)
                        return

        if near_star and ship.fuel < MAX_FUEL:
            ship.fuel = min(MAX_FUEL, ship.fuel + 0.4)
            if pyxel.frame_count % 20 == 0:
                pyxel.play(2, 2)

        # ---- trail ----
        ship.trail.append([ship.px, ship.py, 90])
        for t in ship.trail:
            t[2] -= 1
        ship.trail = [t for t in ship.trail if t[2] > 0][-90:]

        # ---- particles ----
        for p in self.particles[:]:
            p.wx += p.vx
            p.wy += p.vy
            p.life -= 1
            if p.life <= 0:
                self.particles.remove(p)

        if self.shake > 0:
            self.shake *= 0.85
            if self.shake < 0.1:
                self.shake = 0.0

        if ship.fuel <= 0 and abs(ship.vx) < 0.02 and abs(ship.vy) < 0.02:
            self._crash(boom_at=(ship.px, ship.py), sound=None)

    def _spawn_engine_particles(self, ex, ey, big):
        """Exhaust particles shoot opposite the applied thrust direction
        (ex, ey), from roughly the ship's position."""
        if pyxel.frame_count % (2 if big else 3) != 0:
            return
        n = 2 if big else 1
        for _ in range(n):
            spread = random.uniform(-0.35, 0.35)
            ang = math.atan2(-ey, -ex) + spread
            spd = random.uniform(1.2, 2.8) if big else random.uniform(0.6, 1.4)
            life = random.randint(10, 16) if big else random.randint(6, 10)
            col = random.choice([10, 9, 8]) if big else random.choice([12, 6, 7])
            self.particles.append(Particle(
                self.ship.px, self.ship.py,
                math.cos(ang) * spd, math.sin(ang) * spd, life, col,
            ))

    def _crash(self, boom_at, sound):
        self.state = STATE_OVER
        pyxel.stop(0)
        pyxel.stop(1)
        if sound is not None:
            pyxel.play(2, sound)
        for _ in range(30):
            ang = random.uniform(0, math.tau)
            spd = random.uniform(1, 5)
            self.particles.append(Particle(
                boom_at[0], boom_at[1],
                math.cos(ang) * spd, math.sin(ang) * spd,
                random.randint(15, 30), random.choice([8, 9, 10, 7]),
            ))

    # ------------------------------------------------------------------ #
    def draw(self):
        pyxel.cls(0)
        if self.state == STATE_TITLE:
            self._draw_title()
            return

        ship = self.ship
        z = self.zoom
        shx = random.uniform(-self.shake, self.shake) if self.shake else 0
        shy = random.uniform(-self.shake, self.shake) if self.shake else 0
        cx, cy = ship.px, ship.py

        self._draw_stars(cx, cy)
        nearby = self.get_planets(cx, cy)

        if self.state == STATE_PLAY:
            self._draw_trajectory(cx, cy, nearby, z, shx, shy)

        self._draw_trail(cx, cy, z, shx, shy)
        self._draw_planets(cx, cy, nearby, z, shx, shy)
        self._draw_particles(cx, cy, z, shx, shy)

        if self.state == STATE_PLAY:
            self._draw_ship(shx, shy)

        self._draw_hud()
        self._draw_minimap(nearby, cx, cy)

        if self.state == STATE_OVER:
            self._draw_gameover()

    # ------------------------------------------------------------------ #
    def _draw_title(self):
        for sx, sy, d in self.stars:
            pyxel.pset(sx, sy, 5 if d < 0.5 else 7)
        text_big(WIDTH // 2 - 160, HEIGHT // 2 - 70, "G R A V I T Y   V O Y A G E R", 10, 2)
        text_big(WIDTH // 2 - 150, HEIGHT // 2 - 30, "LEFT/RIGHT rotate, UP main engine, DOWN retro", 7, 1)
        text_big(WIDTH // 2 - 150, HEIGHT // 2 - 12, "W A S D = RCS thrusters, Z/X = zoom", 7, 1)
        text_big(WIDTH // 2 - 130, HEIGHT // 2 + 6, "avoid black holes. refuel near stars.", 6, 1)
        if pyxel.frame_count % 60 < 40:
            text_big(WIDTH // 2 - 100, HEIGHT // 2 + 40, "PRESS ENTER TO LAUNCH", 8, 2)

    def _draw_gameover(self):
        pyxel.rect(CX - 220, CY - 60, 440, 120, 0)
        pyxel.rectb(CX - 220, CY - 60, 440, 120, 8)
        text_big(CX - 130, CY - 30, "SHIP LOST", 8, 3)
        text_big(CX - 150, CY + 10, "press R to try again", 7, 2)

    def _draw_stars(self, cx, cy):
        for sx, sy, d in self.stars:
            pyxel.pset(
                (sx - cx * 0.05 * d) % WIDTH,
                (sy - cy * 0.05 * d) % HEIGHT,
                5 if d < 0.5 else 7,
            )

    def _danger_color(self, min_dist, rad, will_hit):
        if will_hit:
            return 8
        if min_dist < rad * 4:
            return 9
        return 11

    def _draw_trajectory(self, cx, cy, nearby, z, shx, shy):
        t_px, t_py = cx, cy
        t_vx, t_vy = self.ship.vx, self.ship.vy
        col_by_step = 11
        for i in range(700):
            t_px += t_vx
            t_py += t_vy
            will_hit = False
            min_dist_here = 1e9
            for p in nearby:
                tdx, tdy = p["x"] - t_px, p["y"] - t_py
                tdist_sq = max(tdx * tdx + tdy * tdy, 150)
                tdist = math.sqrt(tdist_sq)
                min_dist_here = min(min_dist_here, tdist - p["rad"])
                if tdist_sq < 90000:
                    t_f = (G * p["m"]) / tdist_sq
                    t_vx += t_f * (tdx / tdist)
                    t_vy += t_f * (tdy / tdist)
                hit_radius = p["rad"] * (2.2 if p["deadly"] else 1.0) + 2
                if tdist < hit_radius:
                    will_hit = True
            col_by_step = self._danger_color(min_dist_here, 20, will_hit)
            if i % 3 == 0:
                sx = CX + (t_px - cx) * z + shx
                sy = CY + (t_py - cy) * z + shy
                pyxel.pset(sx, sy, col_by_step)
            if will_hit:
                break

    def _draw_trail(self, cx, cy, z, shx, shy):
        for wx, wy, life in self.ship.trail:
            sx = CX + (wx - cx) * z + shx
            sy = CY + (wy - cy) * z + shy
            col = 5 if life < 45 else 6
            pyxel.pset(sx, sy, col)

    def _draw_planets(self, cx, cy, nearby, z, shx, shy):
        for p in nearby:
            sx = CX + (p["x"] - cx) * z + shx
            sy = CY + (p["y"] - cy) * z + shy
            r = p["rad"] * z
            if r < -4 or sx < -50 or sx > WIDTH + 50 or sy < -50 or sy > HEIGHT + 50:
                continue
            mid, core, rim = p["cols"]

            if p["name"] == "black hole":
                for ring_r, col in ((r * 2.4, 5), (r * 1.7, 1)):
                    pyxel.circb(sx, sy, ring_r, col)
                pyxel.circ(sx, sy, r, core)
                continue

            if p["rings"]:
                pyxel.elli(sx - r * 1.8, sy - r * 0.6, r * 3.6, r * 1.2, mid)
                pyxel.ellib(sx - r * 1.8, sy - r * 0.6, r * 3.6, r * 1.2, rim)

            pyxel.circ(sx, sy, r, mid)
            pyxel.circ(sx - r * 0.3, sy - r * 0.3, r * 0.6, core)
            pyxel.circb(sx, sy, r, rim)

            if p["name"] == "star":
                pyxel.circb(sx, sy, r + 3 + math.sin(pyxel.frame_count * 0.1) * 2, 10)

    def _draw_particles(self, cx, cy, z, shx, shy):
        for p in self.particles:
            sx = CX + (p.wx - cx) * z + shx
            sy = CY + (p.wy - cy) * z + shy
            size = max(1, int(p.life / 8))
            pyxel.circ(sx, sy, size, p.col if p.life > 6 else 5)

    def _draw_ship(self, shx, shy):
        x, y = CX + shx, CY + shy
        ang = self.ship.angle
        s = SCALE * 0.9
        fx, fy = math.cos(ang), math.sin(ang)
        px_, py_ = math.cos(ang + math.pi / 2), math.sin(ang + math.pi / 2)
        nose = (x + fx * s * 1.6, y + fy * s * 1.6)
        left = (x - fx * s * 1.0 + px_ * s * 0.9, y - fy * s * 1.0 + py_ * s * 0.9)
        right = (x - fx * s * 1.0 - px_ * s * 0.9, y - fy * s * 1.0 - py_ * s * 0.9)
        pyxel.tri(nose[0], nose[1], left[0], left[1], right[0], right[1], 12)
        pyxel.circ(x + fx * s * 0.3, y + fy * s * 0.3, s * 0.3, 7)

    def _draw_hud(self):
        ship = self.ship
        pad = 14
        bar_w, bar_h = 26 * (SCALE // 4), 14

        text_big(pad, pad, f"ZOOM {self.zoom:.1f}  (Z/X)", 7, 1)

        y = pad + 18 + 30
        pyxel.rect(pad, y, bar_w, bar_h, 1)
        pyxel.rect(pad, y, int(bar_w * ship.fuel / MAX_FUEL), bar_h, 11)
        pyxel.rectb(pad, y, bar_w, bar_h, 7)
        text_big(pad + 4, y + 3, f"FUEL {int(ship.fuel)}%", 0, 1)

        y += bar_h + 10
        pyxel.rect(pad, y, bar_w, bar_h, 1)
        pyxel.rect(pad, y, int(bar_w * max(0, ship.hull) / MAX_HULL), bar_h, 8)
        pyxel.rectb(pad, y, bar_w, bar_h, 7)
        text_big(pad + 4, y + 3, f"HULL {max(0, int(ship.hull))}%", 0, 1)

        y += bar_h + 12
        speed = math.hypot(ship.vx, ship.vy)
        heading = int(math.degrees(ship.angle)) % 360
        text_big(pad, y, f"SPEED {speed:.2f}   HEADING {heading} deg", 7, 1)

    def _draw_minimap(self, nearby, cx, cy):
        panel = 20 * SCALE
        margin = 14
        mx, my = WIDTH - panel - margin, margin+45  # top-right, clear of the HUD
        pyxel.rect(mx - 2, my - 2, panel + 4, panel + 4, 0)
        pyxel.rectb(mx - 2, my - 2, panel + 4, panel + 4, 5)

        half = panel / 2
        center_x, center_y = mx + half, my + half
        map_range = 900.0  # world units shown out to the panel edge

        for p in nearby:
            dx, dy = p["x"] - cx, p["y"] - cy
            if abs(dx) > map_range or abs(dy) > map_range:
                continue
            mxp = center_x + dx / map_range * half
            myp = center_y + dy / map_range * half
            col = 8 if p["deadly"] else p["cols"][0]
            pyxel.circ(mxp, myp, 2, col)

        pyxel.circ(center_x, center_y, 2, 7)
        ang = self.ship.angle
        pyxel.line(
            center_x, center_y,
            center_x + math.cos(ang) * 10, center_y + math.sin(ang) * 10,
            10,
        )
        text_big(mx, my + panel + 4, "MAP", 7, 1)

App()