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>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user