Files
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

203 lines
7.3 KiB
GDScript

extends RefCounted
class_name BlackHole
const GRAVITY_STRENGTH := 0.8
# Original was 150px for ~1440px-wide window (10% of screen).
# Our canvas is 960px; 10% = 96px. Use ~160 for noticeable-but-fair gravity.
const PULL_RADIUS := 160.0
const SWALLOW_RADIUS := 14.0 # slightly smaller for fairer play
const MAX_BH_SPEED := 0.72 # 0.3 * 2.4
const DRIFT_PER_TICK := 0.012 # 0.005 * 2.4
const SUPERNOVA_AT := 30 # original value
const MAX_RADIUS := 60.0 # reasonable size at 960px
const FORCE_MULT := 45.0 # tuned: meaningful pull without instant death
const OBJ_MAX_VEL := 5.0 # velocity cap for attracted objects
# Hunting constants — slow drift, creates tension without instant death
const HUNT_DETECTION := 500.0 # roughly half-screen detection radius
const HUNT_ACCEL := 0.008 # very gentle acceleration toward player
const HUNT_MAX_SPEED := 0.38 # original 0.6 on 1440px → 0.6*(960/1440) = 0.4
const HUNT_LOSE_FRAMES := 180 # stops hunting if player out of range for 3s
const HUNT_CHANCE := 0.18
# SMBH lifespan — after SMBH_MAX_LIFE seconds it collapses
const SMBH_MAX_LIFE := 45.0
const SMBH_COLLAPSE_DURATION := 4.0
var x: float; var y: float
var vx: float = 0.0; var vy: float = 0.0
var radius: float = 12.0
var base_radius: float = 12.0
var pull_radius: float = PULL_RADIUS
var gravity: float = GRAVITY_STRENGTH
var consumed: int = 0
var pulse: float = 0.0
var dead: bool = false
var is_smbh: bool = false
var eject_timer: float = 0.0
var hunting: bool = false
var hunt_lost_timer: int = 0
var smbh_life: float = 0.0
var smbh_dying: bool = false
var smbh_collapse_timer: float = 0.0
var flash_intensity: float = 0.0
var flash_color: Color = Color(1.0, 1.0, 1.0, 0.0)
var accretion_flare: float = 0.0
func init(px: float, py: float, _mobile: bool = false) -> void:
x = px; y = py
var r := randf_range(6.0, 12.0) * 2.4 # 6..12 * 2.4
radius = r
base_radius = r
# Initial velocity: ±0.36 (original ±0.15 * 2.4)
vx = randf_range(-0.36, 0.36)
vy = randf_range(-0.36, 0.36)
hunting = randf() < HUNT_CHANCE
func become_smbh() -> void:
is_smbh = true
radius = randf_range(60.0, 90.0) # original 55-75 * ~1.4
base_radius = radius
pull_radius = min(PULL_RADIUS * 4.0, pull_radius * 2.5)
gravity = min(GRAVITY_STRENGTH * 4.0, gravity * 2.0)
func update(delta: float, players: Array, world_w: float, world_h: float) -> void:
pulse += delta * 2.0
eject_timer += delta
# Per-tick micro-drift — matches original rand(-0.005, 0.005) per frame * 2.4
vx += randf_range(-DRIFT_PER_TICK, DRIFT_PER_TICK)
vy += randf_range(-DRIFT_PER_TICK, DRIFT_PER_TICK)
# Hunting: 18% of BHs chase player within detection radius
if hunting and players.size() > 0:
var nearest_dist := INF
var nearest_p = null
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_p = p
if nearest_p != null and nearest_dist < HUNT_DETECTION and nearest_dist > 0.0:
var dx: float = float(nearest_p.x) - x
var dy: float = float(nearest_p.y) - y
var dist: float = sqrt(dx*dx + dy*dy)
vx += (dx/dist) * HUNT_ACCEL
vy += (dy/dist) * HUNT_ACCEL
var sp := sqrt(vx*vx + vy*vy)
if sp > HUNT_MAX_SPEED:
vx = vx/sp * HUNT_MAX_SPEED
vy = vy/sp * HUNT_MAX_SPEED
hunt_lost_timer = 0
else:
hunt_lost_timer += 1
if hunt_lost_timer >= HUNT_LOSE_FRAMES:
hunting = false
# SMBH lifespan — collapses after SMBH_MAX_LIFE seconds
if is_smbh and not dead:
smbh_life += delta
if smbh_life >= SMBH_MAX_LIFE and not smbh_dying:
smbh_dying = true
smbh_collapse_timer = SMBH_COLLAPSE_DURATION
if smbh_dying:
smbh_collapse_timer -= delta
radius = max(8.0, base_radius * (smbh_collapse_timer / SMBH_COLLAPSE_DURATION))
if smbh_collapse_timer <= 0.0:
dead = true
# Clamp wander speed
vx = clamp(vx, -MAX_BH_SPEED, MAX_BH_SPEED)
vy = clamp(vy, -MAX_BH_SPEED, MAX_BH_SPEED)
x += vx; y += vy
# Soft bounce off edges (5% / 95% margins like original)
if x < world_w * 0.05: vx = abs(vx)
elif x > world_w * 0.95: vx = -abs(vx)
if y < world_h * 0.05: vy = abs(vy)
elif y > world_h * 0.95: vy = -abs(vy)
# Consume flash/flare decay
if flash_intensity > 0.0:
flash_intensity = maxf(0.0, flash_intensity - delta * 3.0)
if accretion_flare > 0.0:
accretion_flare = maxf(0.0, accretion_flare - delta * 1.5)
func check_swallow(ox: float, oy: float) -> bool:
var dx := ox - x; var dy := oy - y
return sqrt(dx*dx + dy*dy) < SWALLOW_RADIUS + radius * 0.5
func apply_gravity(ox: float, oy: float, ovx: float, ovy: float) -> Vector2:
var dx := ox - x; var dy := oy - y
var dist := sqrt(dx*dx + dy*dy)
var nvx := ovx; var nvy := ovy
if dist < pull_radius and dist > 0.01:
var force := gravity / (dist * dist) * FORCE_MULT
nvx += (dx/dist) * -force
nvy += (dy/dist) * -force
return Vector2(nvx, nvy)
func on_swallow() -> bool:
consumed += 1
# Growth formula from original: radius = min(MAX, base + consumed * 0.4) * 2.4
radius = min(MAX_RADIUS, base_radius + consumed * 0.96) # 0.4 * 2.4
pull_radius = min(PULL_RADIUS * 2.0, PULL_RADIUS + consumed * 7.2) # 3 * 2.4
gravity = min(GRAVITY_STRENGTH * 2.0, GRAVITY_STRENGTH + consumed * 0.05)
if consumed >= SUPERNOVA_AT and not is_smbh:
return true
return false
func trigger_flash(col: Color, intensity: float = 1.0) -> void:
flash_intensity = intensity
flash_color = col
accretion_flare = intensity
func draw(canvas: CanvasItem) -> void:
var p := 0.6 + 0.4 * sin(pulse)
var cv := Vector2(x, y)
var ring_r := radius + 4.0 + 4.0 * sin(pulse)
var ring_col := Color(0.6, 0.2, 1.0, p * 0.9) if not is_smbh else Color(1.0, 0.4, 0.0, p)
# Outer glow halo — drawn first so core covers it
canvas.draw_circle(cv, radius + ring_r * 0.35, Color(ring_col.r, ring_col.g, ring_col.b, p * 0.08))
# Accretion disk: rotating segmented arcs — flare brightens + thickens on consume
var flare_mult: float = 1.0 + accretion_flare * 2.0
var disk_w: float = 2.5 + accretion_flare * 3.0
var disk_r := ring_r + 8.0
for seg in 6:
var sa := pulse * 0.55 + float(seg) / 6.0 * TAU
var arc_col := Color(ring_col.r, ring_col.g, ring_col.b, minf(ring_col.a * flare_mult, 1.0))
canvas.draw_arc(cv, ring_r, sa, sa + TAU / 6.0 * 0.75, 6, arc_col, disk_w)
for seg in 4:
var sa := -pulse * 0.3 + float(seg) / 4.0 * TAU
canvas.draw_arc(cv, disk_r, sa, sa + TAU / 4.0 * 0.6, 5,
Color(0.9, 0.5, 0.2, minf(p * 0.35 * flare_mult, 1.0)), disk_w * 0.6)
# Black core — single draw_circle replaces O(radius²) pixel loop
canvas.draw_circle(cv, radius, Color(0.0, 0.0, 0.0, 1.0))
# SMBH extra rings
if is_smbh:
for ri in 3:
var er := ring_r + float(ri + 1) * 10.0
var rot_dir := 1.0 if ri % 2 == 0 else -1.0
var sa := pulse * (0.35 + float(ri) * 0.1) * rot_dir
canvas.draw_arc(cv, er, sa, sa + TAU, 24,
Color(1.0, 0.6, 0.0, p * 0.3), 1.5)
# Consume flash — expanding ring + inner glow
if flash_intensity > 0.0:
var flash_r: float = radius + 10.0 + (1.0 - flash_intensity) * 25.0
canvas.draw_arc(cv, flash_r, 0.0, TAU, 24,
Color(flash_color.r, flash_color.g, flash_color.b, flash_intensity * 0.7), 2.5)
canvas.draw_circle(cv, radius + 4.0,
Color(flash_color.r, flash_color.g, flash_color.b, flash_intensity * 0.15))