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:
2026-04-21 14:38:09 +02:00
commit edc40f9008
108 changed files with 10068 additions and 0 deletions
+605
View File
@@ -0,0 +1,605 @@
extends Node2D
# ─── Werkstatt UI ─────────────────────────────────────────────────────────────
# Zwei Phasen:
# PHASE_ATTR — 3 kostenlose Attribut-Upgrades (Pflicht, eins auswählen)
# PHASE_SHOP — Werkstatt: beliebig viele Items kaufen, SPACE beendet
#
# Items kommen aus ItemDB.roll_werkstatt() (Plugin-System unter res://items/).
# Gekaufte Items werden via stats.apply_item_def(def) angewendet und erscheinen
# visuell am Schiff-Preview im Zentrum.
# Emits: shop_closed(remaining_credits, updated_stats, owned_item_ids)
signal shop_closed(remaining_credits: int, new_stats: ShipStats, items: Array, reroll_count: int)
# ─── Konstanten ───────────────────────────────────────────────────────────────
const REROLL_BASE_COST := 60
const REROLL_STEP_COST := 42
const SHOP_CARD_COUNT := 4
const ATTR_CARD_COUNT := 3
# Cockpit palette
const COL_BG := Color(0.0, 0.0, 0.04, 0.92)
const COL_PRIMARY := Color(0.0, 1.0, 0.533, 1.0)
const COL_ACCENT := Color(0.27, 1.0, 0.8, 1.0)
const COL_DIM := Color(0.0, 0.27, 0.13, 0.55)
const COL_WHITE := Color(1.0, 1.0, 1.0, 0.90)
const COL_WARN := Color(1.0, 0.67, 0.0, 0.90)
const COL_POS := Color(0.3, 1.0, 0.4, 1.0)
const COL_NEG := Color(1.0, 0.35, 0.35, 1.0)
# ─── Attribut-Pool (Phase 1 — gratis, reine Buffs) ────────────────────────────
# Jedes Attribut ist ein Dictionary wie ein ItemDef-Effect.
const ATTR_POOL: Array = [
{ "id": "a_speed", "name": "Schubverstärker", "name_en": "Thruster", "desc": "+8% Geschwindigkeit", "desc_en": "+8% Speed", "effects": { "speed_mult": 1.08 } },
{ "id": "a_turn", "name": "Agilität", "name_en": "Agility", "desc": "+12% Wendigkeit", "desc_en": "+12% Handling", "effects": { "turn_mult": 1.12 } },
{ "id": "a_fire", "name": "Schnellfeuer", "name_en": "Rapid Fire", "desc": "+12% Feuerrate", "desc_en": "+12% Fire Rate", "effects": { "fire_rate_mult": 1.12 } },
{ "id": "a_damage", "name": "Schwere Ladung", "name_en": "Heavy Load", "desc": "+12% Schaden", "desc_en": "+12% Damage", "effects": { "damage_mult": 1.12 } },
{ "id": "a_proj", "name": "Lineare Ballistik","name_en": "Linear Ballistics", "desc": "+12% Projektilgeschw.", "desc_en": "+12% Projectile Speed", "effects": { "bullet_speed_mult": 1.12 } },
{ "id": "a_shield", "name": "Panzerplatte", "name_en": "Armor Plate", "desc": "+1 Schutzladung", "desc_en": "+1 Shield Charge", "effects": { "shield_charges": 1 } },
{ "id": "a_invuln", "name": "Schildgenerator", "name_en": "Shield Generator", "desc": "+18% Unverwundbarkeit", "desc_en": "+18% Invulnerability", "effects": { "invuln_mult": 1.18 } },
{ "id": "a_credits", "name": "Profitoptimierer", "name_en": "Profit Optimizer", "desc": "+8% Kreditgewinn", "desc_en": "+8% Credit Gain", "effects": { "credit_bonus": 1.08 } },
]
# ─── Phasen-State ─────────────────────────────────────────────────────────────
enum Phase { ATTR, SHOP }
var _phase: int = Phase.ATTR
# ─── Session-State ────────────────────────────────────────────────────────────
var _lives: int = 3
var _credits: int = 0
var _stats: ShipStats = null
var _owned_ids: Array = []
var _player_label: String = ""
var _ship_palette: Dictionary = {}
var _wave: int = 1
var _player_idx: int = 1 # 1 = P1 (Pfeiltasten/Enter/Space), 2 = P2 (ADWEF/Q)
# Phase 1
var _attr_choices: Array = [] # 3 zufällige Attribute
var _attr_cursor: int = 0
# Phase 2
var _shop_cards: Array = [] # aktuelle 4 ItemDef-Objekte
var _shop_cursor: int = 0
var _reroll_count: int = 0
# UI
var _blink: float = 0.0
var W: float = 960.0
var H: float = 600.0
# Touch state (direct InputEventScreenTouch handling)
var _tc_id: int = -1
var _tc_start: Vector2 = Vector2.ZERO
# ──────────────────────────────────────────────────────────────────────────────
func open(lives: int, credits: int, stats: ShipStats, owned: Array,
player_label: String = "", ship_palette: Dictionary = {},
wave: int = 1, reroll_count: int = 0, player_idx: int = 1) -> void:
_lives = lives
_credits = credits
_stats = stats if stats != null else ShipStats.new()
_owned_ids = owned.duplicate()
_player_label = player_label
_ship_palette = ship_palette
_wave = wave
# Attribut-Phase nur in ungeraden Wellen (1, 3, 5, ...). Gerade Wellen
# starten direkt im Shop — langsamere Power-Kurve.
_phase = Phase.ATTR if (wave % 2) == 1 else Phase.SHOP
_attr_cursor = 0
_shop_cursor = 0
# Reroll-Counter persistiert über Wellen hinweg (von main.gd durchgereicht).
_reroll_count = reroll_count
_player_idx = player_idx
_blink = 0.0
if _phase == Phase.ATTR:
_roll_attr()
_roll_shop()
visible = true
func _roll_attr() -> void:
var pool := ATTR_POOL.duplicate()
pool.shuffle()
_attr_choices = pool.slice(0, ATTR_CARD_COUNT)
func _roll_shop() -> void:
# Exclude already-owned item IDs so you don't see the same thing twice in one roll
_shop_cards = ItemDB.roll_werkstatt(SHOP_CARD_COUNT, _owned_ids)
func _process(delta: float) -> void:
if not visible: return
_blink += delta
queue_redraw()
# ─── Input ────────────────────────────────────────────────────────────────────
func _input(event: InputEvent) -> void:
if not visible: return
if not event is InputEventScreenTouch: return
if event.pressed:
if _tc_id == -1:
_tc_id = event.index
_tc_start = event.position
elif event.index == _tc_id:
_tc_id = -1
var d: Vector2 = event.position - _tc_start
if d.length() < 32.0:
_touch_tap(event.position)
elif abs(d.x) >= abs(d.y):
_touch_nav("ui_left" if d.x < 0 else "ui_right")
else:
_touch_nav("ui_up" if d.y < 0 else "ui_down")
get_viewport().set_input_as_handled()
func _touch_tap(p: Vector2) -> void:
if _phase == Phase.ATTR:
var card_w := 230.0; var card_h := 140.0
var n := _attr_choices.size()
var total_w: float = card_w * n + 20.0 * (n - 1)
var start_x: float = (W - total_w) * 0.5
var cy: float = H * 0.5 - 30.0 - card_h * 0.5 + 40.0
for i in n:
var cx: float = start_x + i * (card_w + 20.0)
if Rect2(cx, cy, card_w, card_h).has_point(p):
_attr_cursor = i
_confirm_attr()
return
else:
var card_w := 250.0; var card_h := 140.0
var card_pos: Array = [
Vector2(W * 0.5 - card_w - 120.0, H * 0.5 - card_h - 50.0),
Vector2(W * 0.5 + 120.0, H * 0.5 - card_h - 50.0),
Vector2(W * 0.5 - card_w - 120.0, H * 0.5 - 10.0),
Vector2(W * 0.5 + 120.0, H * 0.5 - 10.0),
]
for i in min(_shop_cards.size(), card_pos.size()):
if Rect2(card_pos[i].x, card_pos[i].y, card_w, card_h).has_point(p):
_shop_cursor = i
_try_buy(i)
return
if p.y >= H - 52.0:
if p.x < W * 0.5:
_try_reroll()
else:
_close()
func _touch_nav(action: String) -> void:
if _phase == Phase.ATTR:
match action:
"ui_left": _attr_cursor = (_attr_cursor - 1 + _attr_choices.size()) % _attr_choices.size()
"ui_right": _attr_cursor = (_attr_cursor + 1) % _attr_choices.size()
else:
match action:
"ui_left": _move_shop_cursor(-1)
"ui_right": _move_shop_cursor(1)
"ui_up": _move_shop_cursor(-2)
"ui_down": _move_shop_cursor(2)
func _unhandled_input(event: InputEvent) -> void:
if not visible: return
if _phase == Phase.ATTR:
_handle_attr_input(event)
else:
_handle_shop_input(event)
func _handle_attr_input(event: InputEvent) -> void:
if _player_idx == 2:
if event.is_action_pressed("p2_left"):
_attr_cursor = (_attr_cursor - 1 + _attr_choices.size()) % _attr_choices.size()
elif event.is_action_pressed("p2_right"):
_attr_cursor = (_attr_cursor + 1) % _attr_choices.size()
elif event.is_action_pressed("p2_shoot"):
_confirm_attr()
else:
if event.is_action_pressed("ui_left"):
_attr_cursor = (_attr_cursor - 1 + _attr_choices.size()) % _attr_choices.size()
elif event.is_action_pressed("ui_right"):
_attr_cursor = (_attr_cursor + 1) % _attr_choices.size()
elif event is InputEventKey and event.pressed and event.keycode == KEY_SPACE:
_confirm_attr()
elif event.is_action_pressed("ui_accept"):
_confirm_attr()
func _handle_shop_input(event: InputEvent) -> void:
if _player_idx == 2:
if event.is_action_pressed("p2_wipe") or event.is_action_pressed("ui_cancel"):
_close()
elif event.is_action_pressed("p2_left"):
_move_shop_cursor(-1)
elif event.is_action_pressed("p2_right"):
_move_shop_cursor(1)
elif event.is_action_pressed("p2_thrust"):
_move_shop_cursor(-2)
elif event.is_action_pressed("p2_boost"):
_try_reroll()
elif event.is_action_pressed("p2_shoot"):
_try_buy(_shop_cursor)
else:
if event is InputEventKey and event.pressed:
if event.keycode == KEY_SPACE or event.keycode == KEY_ESCAPE:
_close()
return
elif event.keycode == KEY_ENTER:
_try_buy(_shop_cursor)
return
elif event.keycode == KEY_R:
_try_reroll()
return
if event.is_action_pressed("ui_cancel"):
_close()
elif event.is_action_pressed("ui_left"):
_move_shop_cursor(-1)
elif event.is_action_pressed("ui_right"):
_move_shop_cursor(1)
elif event.is_action_pressed("ui_up"):
_move_shop_cursor(-2)
elif event.is_action_pressed("ui_down"):
_move_shop_cursor(2)
elif event.is_action_pressed("ui_accept"):
_try_buy(_shop_cursor)
func _move_shop_cursor(delta: int) -> void:
if _shop_cards.is_empty(): return
_shop_cursor = (_shop_cursor + delta + _shop_cards.size()) % _shop_cards.size()
func _confirm_attr() -> void:
if _attr_cursor >= _attr_choices.size(): return
var choice: Dictionary = _attr_choices[_attr_cursor]
_stats.apply_item(choice["effects"])
_phase = Phase.SHOP
func _try_buy(idx: int) -> void:
if idx < 0 or idx >= _shop_cards.size(): return
var def: ItemDef = _shop_cards[idx]
if _credits < def.cost: return
_credits -= def.cost
_stats.apply_item_def(def)
_owned_ids.append(def.id)
_shop_cards.remove_at(idx)
if _shop_cards.is_empty():
_shop_cursor = 0
else:
_shop_cursor = min(_shop_cursor, _shop_cards.size() - 1)
func _try_reroll() -> void:
var cost: int = REROLL_BASE_COST + _reroll_count * REROLL_STEP_COST
if _credits < cost: return
_credits -= cost
_reroll_count += 1
_roll_shop()
_shop_cursor = 0
func _close() -> void:
visible = false
shop_closed.emit(_credits, _stats, _owned_ids, _reroll_count)
# ═══════════════════════════════════════════════════════════════════════════════
# Drawing
# ═══════════════════════════════════════════════════════════════════════════════
func _draw() -> void:
var vs: Vector2 = get_viewport_rect().size
W = vs.x; H = vs.y
draw_rect(get_viewport_rect(), COL_BG)
_draw_header()
_draw_brackets(8, 8, W - 8, H - 8, COL_DIM, 16)
if _phase == Phase.ATTR:
_draw_attr_phase()
else:
_draw_shop_phase()
_draw_footer()
# ── Header ────────────────────────────────────────────────────────────────────
func _draw_header() -> void:
draw_rect(Rect2(0, 0, W, 50.0), Color(0.0, 0.04, 0.02, 0.95))
draw_line(Vector2(0, 50), Vector2(W, 50), COL_DIM, 1.0)
var phase_key: String = "werk_phase_attr" if _phase == Phase.ATTR else "werk_phase_shop"
var wave_str := Tr.t("werk_wave") % _wave
if _player_label != "":
_draw_text_c(_player_label, W * 0.5, 10.0, 9, COL_DIM)
_draw_text_c(Tr.t(phase_key) + wave_str, W * 0.5, 26.0, 13, COL_PRIMARY)
# Lives + credits oben rechts
var lives_str := ""
for li in 3:
lives_str += ("" if li < _lives else "")
_draw_text(lives_str + " CR: %d" % _credits, W - 190.0, 14.0, 11, COL_PRIMARY)
# ── Attribut-Phase ────────────────────────────────────────────────────────────
func _draw_attr_phase() -> void:
var y_mid := H * 0.5 - 30.0
_draw_text_c(Tr.t("werk_attr_prompt"), W * 0.5, 76.0, 11, COL_ACCENT)
var card_w := 230.0; var card_h := 140.0
var n: int = _attr_choices.size()
var total_w: float = card_w * n + 20.0 * (n - 1)
var start_x: float = (W - total_w) * 0.5
for i in n:
var choice: Dictionary = _attr_choices[i]
var cx: float = start_x + i * (card_w + 20.0)
var cy: float = y_mid - card_h * 0.5 + 40.0
var is_sel: bool = i == _attr_cursor
var pulse: float = 0.5 + 0.5 * sin(_blink * 3.0)
draw_rect(Rect2(cx, cy, card_w, card_h),
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.10))
var bc: Color = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.55 + 0.45 * pulse) if is_sel else COL_DIM
_draw_brackets(cx, cy, cx + card_w, cy + card_h, bc, 14)
_draw_text_c(Tr.t("werk_attr_tag"), cx + card_w * 0.5, cy + 10.0, 8,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.6))
var _en := Settings.language == "en"
_draw_text_c(choice["name_en"] if _en else choice["name"], cx + card_w * 0.5, cy + 32.0, 13, COL_WHITE)
_draw_text_c(choice["desc_en"] if _en else choice["desc"], cx + card_w * 0.5, cy + 62.0, 10, COL_POS)
if is_sel:
var hint_col := Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.6 + 0.4 * pulse)
var pick_key: String = "werk_pick_p2" if _player_idx == 2 else "werk_pick"
_draw_text_c(Tr.hint(pick_key), cx + card_w * 0.5, cy + card_h - 24.0, 10, hint_col)
# ── Werkstatt-Phase ───────────────────────────────────────────────────────────
func _draw_shop_phase() -> void:
# Schiff-Preview in der Mitte (leicht nach oben verschoben)
_draw_ship_preview(W * 0.5, H * 0.5 - 15.0)
# 2×2 Karten um das Schiff herum — nach oben geschoben, damit Vorschau-Panel Platz hat
var card_w := 250.0; var card_h := 140.0
var positions: Array = [
Vector2(W * 0.5 - card_w - 120.0, H * 0.5 - card_h - 50.0), # Top-left y=110
Vector2(W * 0.5 + 120.0, H * 0.5 - card_h - 50.0), # Top-right y=110
Vector2(W * 0.5 - card_w - 120.0, H * 0.5 - 10.0), # Bottom-left y=290
Vector2(W * 0.5 + 120.0, H * 0.5 - 10.0), # Bottom-right y=290
]
for i in _shop_cards.size():
var def: ItemDef = _shop_cards[i]
var p: Vector2 = positions[i] if i < positions.size() else Vector2(20, 80 + i * 40)
_draw_shop_card(def, p.x, p.y, card_w, card_h, i == _shop_cursor)
# Vorschau-Panel — unterhalb der unteren Kartenreihe (y=430), mit 8px Abstand
if not _shop_cards.is_empty() and _shop_cursor < _shop_cards.size():
_draw_preview_panel(_shop_cards[_shop_cursor], W * 0.5, H - 119.0)
# Owned items Banner (oben unter dem Header)
if _owned_ids.size() > 0:
_draw_owned_banner(W * 0.5, 60.0)
func _draw_shop_card(def: ItemDef, x: float, y: float, w: float, h: float, is_sel: bool) -> void:
var pulse: float = 0.5 + 0.5 * sin(_blink * 3.0)
var can_afford: bool = _credits >= def.cost
var rarity_col: Color = ItemDB.RARITY_COLORS.get(def.rarity, COL_PRIMARY)
var bg_a: float = 0.18 if can_afford else 0.08
draw_rect(Rect2(x, y, w, h), Color(rarity_col.r, rarity_col.g, rarity_col.b, bg_a))
var border_col: Color
if is_sel:
border_col = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.55 + 0.45 * pulse)
else:
border_col = Color(rarity_col.r, rarity_col.g, rarity_col.b, 0.35 if can_afford else 0.15)
_draw_brackets(x, y, x + w, y + h, border_col, 12)
# Icon + Kategorie
var _item_en := Settings.language == "en"
var disp_cat := def.category_en if _item_en and def.category_en != "" else def.category
var disp_name := def.name_en if _item_en and def.name_en != "" else def.name
_draw_text(def.icon + " " + disp_cat, x + 12.0, y + 8.0, 8,
Color(rarity_col.r, rarity_col.g, rarity_col.b, 0.7 if can_afford else 0.3))
# Name
var name_a: float = 0.9 if can_afford else 0.35
_draw_text(disp_name, x + 12.0, y + 26.0, 13, Color(COL_WHITE.r, COL_WHITE.g, COL_WHITE.b, name_a))
# Effekte (bis zu 4 Zeilen, + grün / rot)
var line_y: float = y + 58.0
var count := 0
for key: String in def.effects:
if count >= 4: break
var val = def.effects[key]
var is_pos: bool = ItemDB.is_positive_effect(key, val)
var sign_str: String = "+ " if is_pos else " "
var col: Color = COL_POS if is_pos else COL_NEG
if not can_afford:
col = Color(col.r, col.g, col.b, 0.35)
var line: String = "%s%s %s" % [sign_str, ItemDB.stat_display_name(key), _fmt_val(key, val)]
_draw_text(line, x + 16.0, line_y, 9, col)
line_y += 13.0
count += 1
# Cost unten rechts
var cost_col: Color = COL_WARN if not can_afford else Color(1.0, 1.0, 0.6, 0.95)
_draw_text("%d CR" % def.cost, x + w - 60.0, y + h - 20.0, 12, cost_col)
# Hint auf aktiver Karte
if is_sel:
var hint_col: Color = Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.6 + 0.4 * pulse)
var hint: String = Tr.hint("werk_buy_hint_p2" if _player_idx == 2 else "werk_buy_hint_p1") if can_afford else Tr.t("shop_no_credits")
_draw_text(hint, x + 12.0, y + h - 20.0, 9, hint_col)
# ── Preview-Panel: Vorher/Nachher-Diff ────────────────────────────────────────
func _draw_preview_panel(def: ItemDef, cx: float, cy: float) -> void:
var pw := 420.0; var ph := 86.0
var px := cx - pw * 0.5; var py := cy - ph * 0.5
draw_rect(Rect2(px, py, pw, ph), Color(0.0, 0.04, 0.02, 0.9))
_draw_brackets(px, py, px + pw, py + ph, COL_DIM, 10)
_draw_text_c(Tr.t("werk_preview") + " · " + def.name,
cx, py + 6.0, 9, COL_ACCENT)
# Liste: key: before → after
var col_x: float = px + 14.0
var line_y: float = py + 26.0
var count := 0
for key: String in def.effects:
if count >= 3: break
var val = def.effects[key]
var before: float = _stat_value(key)
var after: float = _project_stat(key, val)
var pos: bool = ItemDB.is_positive_effect(key, val)
var col: Color = COL_POS if pos else COL_NEG
var line := "%s: %.2f%.2f" % [ItemDB.stat_display_name(key), before, after]
_draw_text(line, col_x, line_y, 10, col)
line_y += 14.0
count += 1
# Hull-Size Warnung
if def.hull_size_bonus > 0.0:
_draw_text(Tr.t("werk_grows"), px + pw - 150.0, py + ph - 22.0, 9, COL_WARN)
func _stat_value(key: String) -> float:
match key:
"speed_mult": return _stats.speed_mult
"turn_mult": return _stats.turn_mult
"fire_rate_mult": return _stats.fire_rate_mult
"damage_mult": return _stats.damage_mult
"bullet_speed_mult": return _stats.bullet_speed_mult
"bullet_count": return float(_stats.bullet_count)
"shield_charges": return float(_stats.shield_charges)
"invuln_mult": return _stats.invuln_mult
"bh_resist": return _stats.bh_resist
"wipe_mult": return _stats.wipe_mult
"credit_bonus": return _stats.credit_bonus
return 0.0
func _project_stat(key: String, val) -> float:
match key:
"speed_mult","turn_mult","fire_rate_mult","damage_mult", \
"bullet_speed_mult","invuln_mult","wipe_mult","credit_bonus":
return _stat_value(key) * float(val)
"bullet_count","shield_charges":
return _stat_value(key) + float(val)
"bh_resist":
return min(0.9, _stat_value(key) + float(val))
return _stat_value(key)
func _fmt_val(key: String, val) -> String:
match key:
"bullet_count": return "+%d" % int(val)
"shield_charges": return ("+%d" % int(val)) if int(val) >= 0 else "%d" % int(val)
"bh_resist": return "+%.0f%%" % (float(val) * 100.0)
_:
var f: float = float(val)
var pct: float = (f - 1.0) * 100.0
return "%+.0f%%" % pct
# ── Ship preview im Zentrum ───────────────────────────────────────────────────
func _draw_ship_preview(cx: float, cy: float) -> void:
var bw := 180.0; var bh := 180.0
draw_rect(Rect2(cx - bw * 0.5, cy - bh * 0.5, bw, bh),
Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.05))
_draw_brackets(cx - bw * 0.5, cy - bh * 0.5, cx + bw * 0.5, cy + bh * 0.5,
COL_DIM, 14)
_draw_text_c(Tr.t("werk_ship_preview"), cx, cy - bh * 0.5 + 6.0, 8,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.55))
# Schiff zeichnen — gleich wie Spaceship.HULL_PIXELS, skaliert
var hs: float = _stats.hull_scale if _stats != null else 3.0
var preview_scale: float = 3.5 # Extra-Zoom für Preview
var off_mul: float = hs / 3.0 * preview_scale
var rect_sz: float = hs * preview_scale * 0.9
var rect_half: float = rect_sz * 0.5
var heading: float = -PI * 0.5 # Schiff zeigt nach oben
var cos_h: float = cos(heading); var sin_h: float = sin(heading)
for px_data in Spaceship.HULL_PIXELS:
var lx: float = px_data[0] * off_mul
var ly: float = px_data[1] * off_mul
var col: Color = _ship_palette.get(px_data[2], COL_WHITE)
var rx: float = cx + lx * cos_h - ly * sin_h
var ry: float = cy + lx * sin_h + ly * cos_h
draw_rect(Rect2(rx - rect_half, ry - rect_half, rect_sz, rect_sz), col)
# Attachments aus owned_items
var seen: Dictionary = {}
for id: String in _owned_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.get(id, 0))
seen[id] = stack + 1
var stack_off: float = float(stack) * 0.6
for vpx in def.visual_pixels:
var lx: float = (float(vpx[0]) + stack_off) * off_mul
var ly: float = float(vpx[1]) * off_mul
var col: Color = _ship_palette.get(vpx[2], COL_WHITE)
var rx: float = cx + lx * cos_h - ly * sin_h
var ry: float = cy + lx * sin_h + ly * cos_h
draw_rect(Rect2(rx - rect_half, ry - rect_half, rect_sz, rect_sz), col)
func _draw_owned_banner(cx: float, cy: float) -> void:
var names: Array = []
for id: String in _owned_ids:
var def: ItemDef = ItemDB.get_def_by_id(id)
if def != null:
names.append(def.name)
if names.is_empty(): return
var line := Tr.t("shop_owned") + " " + " · ".join(names)
if line.length() > 100:
line = line.substr(0, 97) + ""
_draw_text_c(line, cx, cy, 9, Color(COL_PRIMARY.r, COL_PRIMARY.g, COL_PRIMARY.b, 0.5))
# ── Footer ────────────────────────────────────────────────────────────────────
func _draw_footer() -> void:
draw_rect(Rect2(0, H - 52.0, W, 52.0), Color(0.0, 0.04, 0.02, 0.95))
draw_line(Vector2(0, H - 52.0), Vector2(W, H - 52.0), COL_DIM, 1.0)
var p2: bool = _player_idx == 2
var is_touch: bool = Settings.last_input_device == "touch"
var pulse: float = 0.5 + 0.4 * sin(_blink * 2.5)
if _phase == Phase.ATTR:
if is_touch:
_draw_text_c("● TAP", W * 0.5, H - 26.0, 11,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse))
else:
_draw_text_c(Tr.hint("werk_attr_footer_p2" if p2 else "werk_attr_footer"), W * 0.5, H - 48.0, 9, COL_DIM)
_draw_text_c(Tr.hint("werk_confirm_p2" if p2 else "werk_confirm"), W * 0.5, H - 26.0, 11,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse))
else:
var reroll_cost: int = REROLL_BASE_COST + _reroll_count * REROLL_STEP_COST
var reroll_col: Color = COL_DIM if _credits < reroll_cost else COL_PRIMARY
if is_touch:
# Touch: left half = REROLL tap zone, right half = SKIP tap zone
draw_rect(Rect2(0, H - 52.0, W * 0.5 - 1, 52.0),
Color(reroll_col.r, reroll_col.g, reroll_col.b, 0.06))
draw_rect(Rect2(W * 0.5 + 1, H - 52.0, W * 0.5 - 1, 52.0),
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, 0.06))
var reroll_str: String = ("REROLL %d CR" if Settings.language == "en" else "REROLL %d CR") % reroll_cost
_draw_text_c(reroll_str, W * 0.25, H - 26.0, 10, reroll_col)
var skip_str: String = "SKIP ►" if Settings.language == "en" else "WEITER ►"
_draw_text_c(skip_str, W * 0.75, H - 26.0, 11,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse))
else:
_draw_text_c(Tr.hint("werk_shop_footer_p2" if p2 else "werk_shop_footer"), W * 0.5, H - 48.0, 9, COL_DIM)
var reroll_key: String = "Q" if p2 else "R"
_draw_text_c(Tr.t("werk_reroll") % [reroll_key, reroll_cost], W * 0.30, H - 26.0, 10, reroll_col)
_draw_text_c(Tr.hint("werk_continue_p2" if p2 else "shop_continue"), W * 0.72, H - 26.0, 11,
Color(COL_ACCENT.r, COL_ACCENT.g, COL_ACCENT.b, pulse))
# ═══════════════════════════════════════════════════════════════════════════════
# Text + Shape Helpers
# ═══════════════════════════════════════════════════════════════════════════════
func _draw_text(text: String, x: float, y: float, sz: int, col: Color) -> void:
var font := ThemeDB.fallback_font
draw_string(font, Vector2(x, y + sz), text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)
func _draw_text_c(text: String, x: float, y: float, sz: int, col: Color) -> void:
var font := ThemeDB.fallback_font
var tw: float = font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x
draw_string(font, Vector2(x - tw * 0.5, y + sz), text, HORIZONTAL_ALIGNMENT_LEFT, -1, sz, col)
func _draw_brackets(x1: float, y1: float, x2: float, y2: float, col: Color, arm: float) -> void:
var bw: float = 1.5
draw_line(Vector2(x1, y1), Vector2(x1 + arm, y1), col, bw)
draw_line(Vector2(x1, y1), Vector2(x1, y1 + arm), col, bw)
draw_line(Vector2(x2, y1), Vector2(x2 - arm, y1), col, bw)
draw_line(Vector2(x2, y1), Vector2(x2, y1 + arm), col, bw)
draw_line(Vector2(x1, y2), Vector2(x1 + arm, y2), col, bw)
draw_line(Vector2(x1, y2), Vector2(x1, y2 - arm), col, bw)
draw_line(Vector2(x2, y2), Vector2(x2 - arm, y2), col, bw)
draw_line(Vector2(x2, y2), Vector2(x2, y2 - arm), col, bw)