extends RefCounted class_name Spaceship const THRUST := 0.28 const TURN_SPEED := 0.08 const MAX_SPEED := 7.5 const DRAG := 0.985 const TRAIL_LEN := 22 const INVULN_FRAMES := 90 # ─── Boost-Mechanik (nur für Schiffe mit stats.has_boost) ──────────────────── const BOOST_DURATION := 0.55 # seconds during which the extra impulse applies const BOOST_IMPULSE := 14.0 # instantaneous velocity boost (px/frame) const BOOST_MAX_SPEED_MULT := 2.8 var x: float var y: float var vx: float = 0.0 var vy: float = 0.0 var heading: float = -PI / 2.0 var palette: Dictionary var trail: Array = [] var invuln_timer: int = INVULN_FRAMES var dead: bool = false var is_thrusting: bool = false var player_index: int = 0 var survival_time: float = 0.0 var kills: int = 0 var wipe_bonus: int = 0 var bullet_cooldown: int = 0 const BULLET_COOLDOWN_MAX := 20 # Auflademechanik (nur wenn wk_charge im Inventar) var charge_timer: float = 0.0 const CHARGE_MAX: float = 1.5 # Sekunden für volle Ladung # Roguelite stats — set via apply_stats() after init var stats: ShipStats = null var current_shields: int = 0 # Boost state var boost_cd: float = 0.0 # remaining cooldown (seconds) var boost_active_t: float = 0.0 # remaining boost-phase time (seconds) # Hull offsets — each unit = 1 px at Basis-scale 3.0, scaled by stats.hull_scale. const HULL_PIXELS := [ [6, 0, "nose"], [4, -2, "mid"], [4, 2, "mid"], [2, 0, "dim"], [0, -4, "accent"],[0, 4, "accent"], [0, 0, "bright"], [-2,-2, "dim"], [-2, 2, "dim"], [-4,-4, "shadow"],[-4, 4, "shadow"], [-4, 0, "edge"], ] func init(px: float, py: float, pal: Dictionary, pidx: int) -> void: x = px; y = py palette = pal player_index = pidx invuln_timer = INVULN_FRAMES func apply_stats(s: ShipStats) -> void: stats = s current_shields = s.shield_charges # Called by game_world when the boost input is just_pressed. func try_boost() -> bool: if dead or stats == null or not stats.has_boost: return false if boost_cd > 0.0: return false boost_cd = stats.boost_cooldown_max boost_active_t = BOOST_DURATION vx += cos(heading) * BOOST_IMPULSE vy += sin(heading) * BOOST_IMPULSE return true func update(thrust: bool, turn: int, world_w: float, world_h: float, delta: float) -> void: if dead: return is_thrusting = thrust var eff_turn: float = TURN_SPEED * (stats.turn_mult if stats else 1.0) var eff_thrust: float = THRUST * (stats.speed_mult if stats else 1.0) var eff_max_speed: float = MAX_SPEED * (stats.speed_mult if stats else 1.0) # While a boost phase is active, lift the cap so the impulse isn't clamped. if boost_active_t > 0.0: boost_active_t = max(0.0, boost_active_t - delta) eff_max_speed *= BOOST_MAX_SPEED_MULT heading += turn * eff_turn if thrust: vx += cos(heading) * eff_thrust vy += sin(heading) * eff_thrust vx *= DRAG; vy *= DRAG var spd := sqrt(vx*vx + vy*vy) if spd > eff_max_speed: vx = vx / spd * eff_max_speed vy = vy / spd * eff_max_speed trail.push_front(Vector2(x, y)) if trail.size() > TRAIL_LEN: trail.pop_back() x += vx; y += vy if x < 0: x += world_w elif x > world_w: x -= world_w if y < 0: y += world_h elif y > world_h: y -= world_h if invuln_timer > 0: invuln_timer -= 1 if bullet_cooldown > 0: bullet_cooldown -= 1 if boost_cd > 0.0: boost_cd = max(0.0, boost_cd - delta) survival_time += delta func boost_ratio() -> float: # 0.0 = ready, 1.0 = full cooldown if stats == null or not stats.has_boost or stats.boost_cooldown_max <= 0.0: return 0.0 return clamp(boost_cd / stats.boost_cooldown_max, 0.0, 1.0) func has_charge_weapon() -> bool: return stats != null and stats.owned_item_ids.has("wk_charge") # Welcher Bullet-Style passt zum ersten gefundenen Waffenitem im Inventar. func _get_bullet_style() -> String: if stats == null: return "default" const WEAPON_STYLES: Dictionary = { "wk_charge": "charge", "wk_plasma": "plasma", "wk_rail": "rail", "wk_sniper": "sniper", "wk_laser": "laser", "wk_ion": "ion", "wk_shotgun": "scatter", "wk_scatter": "scatter", "wk_burst": "burst", } for id: String in stats.owned_item_ids: if id in WEAPON_STYLES: return WEAPON_STYLES[id] return "default" func can_shoot() -> bool: return bullet_cooldown <= 0 and not dead func shoot() -> Bullet: if not can_shoot(): return null var cooldown_max := int(float(BULLET_COOLDOWN_MAX) / (stats.fire_rate_mult if stats else 1.0)) bullet_cooldown = max(3, cooldown_max) var b := Bullet.new() var otype := "p1" if player_index == 0 else "p2" b.init(x + cos(heading) * 8.0, y + sin(heading) * 8.0, heading, otype) b.style = _get_bullet_style() if stats: b.vx *= stats.bullet_speed_mult b.vy *= stats.bullet_speed_mult b.effective_hit_radius = Bullet.HIT_RADIUS * stats.damage_mult b.pierce = stats.damage_mult >= 2.0 return b # Aufgeladener Schuss — ratio 0..1 (0.1 Minimum damit Antippen nichts tut) func shoot_charged(charge_ratio: float) -> Bullet: charge_timer = 0.0 if charge_ratio < 0.12 or not can_shoot(): return null var cooldown_max := int(float(BULLET_COOLDOWN_MAX) / (stats.fire_rate_mult if stats else 1.0)) bullet_cooldown = max(8, cooldown_max) var b := Bullet.new() var otype := "p1" if player_index == 0 else "p2" b.init(x + cos(heading) * 8.0, y + sin(heading) * 8.0, heading, otype) b.style = "charge" var dmg: float = stats.damage_mult if stats else 1.0 b.effective_hit_radius = Bullet.HIT_RADIUS * dmg * (1.0 + charge_ratio * 2.5) b.pierce = true # Aufgeladene Schüsse durchdringen immer if stats: b.vx *= stats.bullet_speed_mult * 0.75 # etwas langsamer, aber mächtiger b.vy *= stats.bullet_speed_mult * 0.75 return b # Returns all bullets for this shot (handles multi-shot via stats.bullet_count) func shoot_burst() -> Array: var result: Array = [] var base: Bullet = shoot() if base == null: return result result.append(base) var extra: int = (stats.bullet_count - 1) if stats else 0 const SPREAD := 0.18 # radians between extra shots for i in extra: var offset := SPREAD * ((float(i) / 2.0 + 1.0) * (1.0 if i % 2 == 0 else -1.0)) var angle := heading + offset var b2 := Bullet.new() var otype := "p1" if player_index == 0 else "p2" b2.init(x + cos(angle) * 8.0, y + sin(angle) * 8.0, angle, otype) if stats: b2.vx *= stats.bullet_speed_mult b2.vy *= stats.bullet_speed_mult b2.effective_hit_radius = Bullet.HIT_RADIUS * stats.damage_mult b2.pierce = stats.damage_mult >= 2.0 result.append(b2) return result func is_invulnerable() -> bool: return invuln_timer > 0 func get_draw_alpha(frame: int) -> float: if not is_invulnerable(): return 1.0 return 0.4 if (frame % 16) < 8 else 1.0 func draw(canvas: CanvasItem, frame: int) -> void: if dead: return var alpha := get_draw_alpha(frame) var cos_h := cos(heading); var sin_h := sin(heading) # Hull scale parameters (Basis 3.0) var hs: float = stats.hull_scale if stats else 3.0 var off_mul: float = hs / 3.0 # 1.0 bei Basis, >1 macht Offsets größer var rect_sz: float = hs # 3 px bei Basis var rect_half: float = rect_sz * 0.5 # Trail for i in trail.size(): var t_alpha := (1.0 - float(i) / float(TRAIL_LEN)) * 0.55 * alpha var tc := Color(palette["trail"].r, palette["trail"].g, palette["trail"].b, t_alpha) canvas.draw_rect(Rect2(trail[i].x - 1, trail[i].y - 1, 2, 2), tc) # Thrust flare — intensified during boost var flare_count: int = 3 if boost_active_t > 0.0: flare_count = 6 if is_thrusting or boost_active_t > 0.0: var hot: Color = palette.get("thrustHot", Color.YELLOW) var cool: Color = palette.get("thrustCool", Color.ORANGE) for fi in flare_count: var fx: float = x + (-6.0 - fi * 2.5) * cos_h var fy: float = y + (-6.0 - fi * 2.5) * sin_h var fc: Color = hot if fi == 0 else cool fc.a = alpha * (1.0 - fi * (0.16 if boost_active_t > 0.0 else 0.25)) canvas.draw_rect(Rect2(fx - 1, fy - 1, 3, 3), fc) # Hull pixels for px_data in HULL_PIXELS: var lx: float = px_data[0] * off_mul var ly: float = px_data[1] * off_mul var col_key: String = px_data[2] var col: Color = palette.get(col_key, Color.WHITE) col.a = alpha var rx := x + lx * cos_h - ly * sin_h var ry := y + lx * sin_h + ly * cos_h canvas.draw_rect(Rect2(rx - rect_half, ry - rect_half, rect_sz, rect_sz), col) # Attachments from bought items (drawn on top of hull) if stats != null and stats.owned_item_ids.size() > 0: _draw_attachments(canvas, cos_h, sin_h, off_mul, rect_sz, rect_half, alpha) # Boost indicator ring under the ship when ready (only for boost-ships) if stats != null and stats.has_boost and boost_cd <= 0.0 and not is_invulnerable(): var ring_col: Color = palette.get("accent", Color(1,1,0,1)) ring_col.a = 0.35 * alpha var r: float = 10.0 * off_mul canvas.draw_arc(Vector2(x, y), r, 0.0, TAU, 16, ring_col, 1.0, false) func _draw_attachments(canvas: CanvasItem, cos_h: float, sin_h: float, off_mul: float, rect_sz: float, rect_half: float, alpha: float) -> void: # Count how many of each item — use for stacking offset var seen_count: Dictionary = {} for id in stats.owned_item_ids: var def: ItemDef = ItemDB.get_def_by_id(id) if def == null or def.visual_pixels.is_empty(): continue var stack: int = int(seen_count.get(id, 0)) seen_count[id] = stack + 1 var stack_off: float = float(stack) * 0.6 # slight per-stack visual offset for px in def.visual_pixels: var lx: float = (float(px[0]) + stack_off) * off_mul var ly: float = float(px[1]) * off_mul var key: String = px[2] var col: Color = palette.get(key, Color.WHITE) col.a = alpha var rx := x + lx * cos_h - ly * sin_h var ry := y + lx * sin_h + ly * cos_h canvas.draw_rect(Rect2(rx - rect_half, ry - rect_half, rect_sz, rect_sz), col)