Files
spacel/scripts/spaceship.gd
T
alpacaman edc40f9008 Initial commit — Godot space roguelite source
- Touch controls: direct InputEventScreenTouch in shop_ui (bypass relay)
- ItemDB: static preload list instead of DirAccess scan (export fix)
- All 18 items with EN localisation (name_en, desc_en, category_en)
- Ship playstyles: NOVA-1 shield, INFERNO ram, AURORA agile/tank
- Quasar: SMBH visual, jet boost, merge, push, BH-eating
- Atlas & UI text updated EN+DE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:38:09 +02:00

276 lines
9.6 KiB
GDScript

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)