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))