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
+248
View File
@@ -0,0 +1,248 @@
extends RefCounted
class_name EnemyShip
const THRUST := 0.19
const DRAG := 0.988
const MAX_SPEED := 4.8
const TRAIL_LEN := 16
const ATTACK_RANGE := 600.0
const FIRE_INTERVAL := 90
const RESPAWN_MIN := 4.0
const RESPAWN_MAX := 8.0
const SEP_RADIUS := 110.0 # anti-clump separation distance
const ORBIT_RADIUS := 180.0 # circle-strafe orbit distance
# Three attack roles that prevent enemies from all rushing from the same angle.
enum Role { AGGRO, CIRCLE, FLANK }
var x: float; var y: float
var vx: float = 0.0; var vy: float = 0.0
var heading: float = 0.0
var trail: Array = []
var dead: bool = false
var respawn_timer: float = 0.0
var fire_timer: int = 0
var enemy_index: int = 0 # 0=red, 1=cyan (colour variant)
var role_id: int = 0 # sequential id, persists across respawns
var role: int = Role.AGGRO
var orbit_offset: float = 0.0
var patrol_timer: int = 0
var patrol_target: Vector2 = Vector2.ZERO
var stats: ShipStats = null
var current_shields: int = 0
var invuln_timer: int = 0
func apply_stats(s: ShipStats) -> void:
stats = s
current_shields = s.shield_charges
# Hull offsets ×2, rendered as 3×3 rects
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"],
]
var palette: Dictionary
func init(px: float, py: float, idx: int, rid: int = 0) -> void:
x = px; y = py
enemy_index = idx
role_id = rid
role = rid % 3
# Each role_id starts at a different point on the orbit so enemies spread out
orbit_offset = float(rid) * (TAU / 3.0)
dead = false
respawn_timer = 0.0
# Stagger fire timers so enemies don't all shoot at the same frame
fire_timer = FIRE_INTERVAL + rid * 17
patrol_target = Vector2(px + randf_range(-250.0, 250.0), py + randf_range(-250.0, 250.0))
patrol_timer = randi_range(60, 180)
invuln_timer = 60
if stats: current_shields = stats.shield_charges
if idx == 0:
palette = {
"nose": Color("#ff8888"), "bright": Color("#ff4444"), "mid": Color("#cc2222"),
"dim": Color("#882222"), "accent": Color("#ffaaaa"), "edge": Color("#441111"),
"shadow": Color("#220000"), "trail": Color(1.0, 0.2, 0.2, 0.25),
"thrustHot": Color("#ffcc44"), "thrustCool": Color(1.0, 0.4, 0.0, 0.5)
}
else:
palette = {
"nose": Color("#88ffff"), "bright": Color("#44ccff"), "mid": Color("#2288cc"),
"dim": Color("#224488"), "accent": Color("#aaccff"), "edge": Color("#112244"),
"shadow": Color("#001122"), "trail": Color(0.2, 0.8, 1.0, 0.25),
"thrustHot": Color("#aaffcc"), "thrustCool": Color(0.2, 0.8, 1.0, 0.5)
}
# enemies array is passed in so each enemy can compute separation from allies.
func update(players: Array, black_holes: Array, enemies: Array, world_w: float, world_h: float, delta: float) -> Bullet:
if dead:
respawn_timer -= delta
return null
if invuln_timer > 0:
invuln_timer -= 1
var eff_thrust: float = THRUST * (stats.speed_mult if stats else 1.0)
var eff_max: float = MAX_SPEED * (stats.speed_mult if stats else 1.0)
var eff_turn: float = 0.1 * (stats.turn_mult if stats else 1.0)
var eff_interval: int = max(10, int(float(FIRE_INTERVAL) / (stats.fire_rate_mult if stats else 1.0)))
# Find nearest living player
var nearest_player = null
var nearest_dist := INF
for p in players:
if p.dead: continue
var dx: float = float(p.x) - x
var dy: float = float(p.y) - y
var d: float = sqrt(dx*dx + dy*dy)
if d < nearest_dist:
nearest_dist = d
nearest_player = p
# Black-hole avoidance — steer away when too close to pull radius
var avoid_vx := 0.0; var avoid_vy := 0.0
for bh in black_holes:
var dx: float = x - float(bh.x)
var dy: float = y - float(bh.y)
var d: float = sqrt(dx*dx + dy*dy)
if d < bh.pull_radius * 1.3 and d > 0.01:
avoid_vx += (dx / d) * 3.0
avoid_vy += (dy / d) * 3.0
# Separation force — push away from nearby allies to prevent clumping
var sep_vx := 0.0; var sep_vy := 0.0
for en in enemies:
var oe: EnemyShip = en
if oe == self or oe.dead: continue
var dx: float = x - oe.x
var dy: float = y - oe.y
var d: float = sqrt(dx*dx + dy*dy)
if d < SEP_RADIUS and d > 0.01:
var strength: float = (SEP_RADIUS - d) / SEP_RADIUS
sep_vx += (dx / d) * strength * 2.0
sep_vy += (dy / d) * strength * 2.0
# Role-based movement
if nearest_player != null and nearest_dist < ATTACK_RANGE:
var pdx: float = float(nearest_player.x) - x
var pdy: float = float(nearest_player.y) - y
_move_attack(pdx, pdy, eff_thrust, eff_turn)
else:
_move_patrol(eff_thrust, eff_turn, world_w, world_h)
vx += (avoid_vx + sep_vx) * 0.05
vy += (avoid_vy + sep_vy) * 0.05
vx *= DRAG; vy *= DRAG
var spd := sqrt(vx*vx + vy*vy)
if spd > eff_max:
vx = vx / spd * eff_max
vy = vy / spd * eff_max
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
fire_timer -= 1
if fire_timer <= 0 and nearest_player != null and nearest_dist < ATTACK_RANGE:
fire_timer = eff_interval
# Aim toward player with distance-based spread:
# accurate at close range (~0.08 rad), less so at max range (~0.32 rad).
var fdx: float = float(nearest_player.x) - x
var fdy: float = float(nearest_player.y) - y
var spread: float = lerp(0.08, 0.32, clamp(nearest_dist / ATTACK_RANGE, 0.0, 1.0))
var fire_angle: float = atan2(fdy, fdx) + randf_range(-spread, spread)
var b := Bullet.new()
b.init(x + cos(fire_angle)*8.0, y + sin(fire_angle)*8.0, fire_angle, "enemy")
if stats:
b.vx *= stats.bullet_speed_mult
b.vy *= stats.bullet_speed_mult
b.effective_hit_radius = Bullet.HIT_RADIUS * stats.damage_mult
return b
return null
# Role.AGGRO: rush straight at player.
# Role.CIRCLE: maintain orbit around player at ORBIT_RADIUS.
# Role.FLANK: approach from a perpendicular angle left or right.
func _move_attack(pdx: float, pdy: float, eff_thrust: float, eff_turn: float) -> void:
match role:
Role.AGGRO:
var ta := atan2(pdy, pdx)
var ad := fmod(ta - heading + PI * 3.0, TAU) - PI
heading += clamp(ad, -eff_turn, eff_turn)
vx += cos(heading) * eff_thrust
vy += sin(heading) * eff_thrust
Role.CIRCLE:
orbit_offset += 0.012
# Target = player position + point on orbit circle
var player_x: float = x + pdx
var player_y: float = y + pdy
var orbit_x: float = player_x + cos(orbit_offset) * ORBIT_RADIUS
var orbit_y: float = player_y + sin(orbit_offset) * ORBIT_RADIUS
var tdx: float = orbit_x - x
var tdy: float = orbit_y - y
var ta: float = atan2(tdy, tdx)
var ad: float = fmod(ta - heading + PI * 3.0, TAU) - PI
heading += clamp(ad, -eff_turn * 1.2, eff_turn * 1.2)
vx += cos(heading) * eff_thrust
vy += sin(heading) * eff_thrust
Role.FLANK:
# Approach from +90° or 90° relative to the direct line to player
var direct: float = atan2(pdy, pdx)
var flank_side: float = PI * 0.5 if (role_id % 2 == 0) else -PI * 0.5
var player_x: float = x + pdx
var player_y: float = y + pdy
var flank_x: float = player_x + cos(direct + flank_side) * 80.0
var flank_y: float = player_y + sin(direct + flank_side) * 80.0
var tdx: float = flank_x - x
var tdy: float = flank_y - y
var ta: float = atan2(tdy, tdx)
var ad: float = fmod(ta - heading + PI * 3.0, TAU) - PI
heading += clamp(ad, -eff_turn, eff_turn)
vx += cos(heading) * eff_thrust
vy += sin(heading) * eff_thrust
func _move_patrol(eff_thrust: float, eff_turn: float, world_w: float, world_h: float) -> void:
patrol_timer -= 1
var to_target := Vector2(patrol_target.x - x, patrol_target.y - y)
# Pick a new target when close enough or timer expires → spreads enemies across map
if patrol_timer <= 0 or to_target.length() < 40.0:
patrol_target = Vector2(
randf_range(world_w * 0.1, world_w * 0.9),
randf_range(world_h * 0.1, world_h * 0.9))
patrol_timer = randi_range(120, 300)
var angle: float = atan2(to_target.y, to_target.x)
var ad: float = fmod(angle - heading + PI * 3.0, TAU) - PI
heading += clamp(ad, -eff_turn * 0.8, eff_turn * 0.8)
vx += cos(heading) * eff_thrust * 0.6
vy += sin(heading) * eff_thrust * 0.6
func draw(canvas: CanvasItem) -> void:
if dead: return
var cos_h := cos(heading); var sin_h := sin(heading)
for i in trail.size():
var t_alpha := (1.0 - float(i) / float(TRAIL_LEN)) * 0.5
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)
for px_data in HULL_PIXELS:
var lx: float = px_data[0]
var ly: float = px_data[1]
var col: Color = palette.get(px_data[2], Color.WHITE)
var rx := x + lx * cos_h - ly * sin_h
var ry := y + lx * sin_h + ly * cos_h
canvas.draw_rect(Rect2(rx - 1, ry - 1, 3, 3), col)