Files
networkcity/Sources/NetworkCityApp/BuildingNode.swift
T
timban 02f48ddd56 Add idle district lifecycle: decay then reap
The map only ever gained density: hubs were sized by lifetime-cumulative
bytes (so they never shrank) and were never removed. Now each hub has a
decaying activity level — quiet districts visibly shrink toward a dormant
dot — and after a configurable idle timeout (~40s, NC_REAP_TICKS) the hub
is demolished: faded out, removed with its road, and its per-org state
pruned in the controller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:46:53 -07:00

227 lines
8.0 KiB
Swift

import SpriteKit
/// A single endpoint (one server IP) belonging to an org a small glowing
/// satellite that appears when its district fans out.
final class EndpointNode: SKNode {
private let glow: SKSpriteNode
private let core: SKShapeNode
override init() {
glow = SKSpriteNode(texture: Textures.softCircle)
core = SKShapeNode(circleOfRadius: 2.5)
super.init()
glow.color = Palette.building
glow.colorBlendFactor = 1
glow.blendMode = .add
glow.setScale(0.12)
glow.alpha = 0.7
addChild(glow)
core.fillColor = Palette.building
core.strokeColor = .white
core.lineWidth = 0.4
addChild(core)
}
required init?(coder: NSCoder) { fatalError() }
func pulse() {
core.removeAction(forKey: "pulse")
core.run(.sequence([.scale(to: 1.8, duration: 0.12), .scale(to: 1.0, duration: 0.3)]), withKey: "pulse")
glow.removeAction(forKey: "flare")
glow.run(.sequence([.fadeAlpha(to: 1.0, duration: 0.12), .fadeAlpha(to: 0.7, duration: 0.4)]), withKey: "flare")
}
}
/// A destination *organization* rendered as a glowing district hub. When busy it
/// fans out into its known endpoints (hub-and-spoke); when quiet it collapses
/// back to a single labelled hub.
final class BuildingNode: SKNode {
private let glow: SKSpriteNode
private let core: SKShapeNode
private let title: SKLabelNode
private let subtitle: SKLabelNode
private var activity: Double = 0
private let spokeLayer = SKNode()
private let endpointLayer = SKNode()
private var endpointNodes: [String: EndpointNode] = [:]
private var knownHosts: Set<String> = []
private(set) var isExpanded = false
private let maxEndpoints = 20
private let fanSpread = 75.0 * .pi / 180 // half-arc the spokes spread over
private let spokeRadius: CGFloat = 82
init(title titleText: String, subtitle subtitleText: String) {
glow = SKSpriteNode(texture: Textures.softCircle)
core = SKShapeNode(circleOfRadius: 5)
title = SKLabelNode(text: titleText)
subtitle = SKLabelNode(text: subtitleText)
super.init()
spokeLayer.zPosition = -1
addChild(spokeLayer)
addChild(endpointLayer)
glow.color = Palette.building
glow.colorBlendFactor = 1
glow.blendMode = .add
glow.setScale(0.30)
glow.alpha = 0.85
addChild(glow)
core.fillColor = Palette.building
core.strokeColor = .white
core.lineWidth = 0.5
core.glowWidth = 1
addChild(core)
title.fontName = "Menlo-Bold"
title.fontSize = 13
title.fontColor = .white
title.verticalAlignmentMode = .top
title.position = CGPoint(x: 0, y: -12)
addChild(title)
subtitle.fontName = "Menlo"
subtitle.fontSize = 10
subtitle.fontColor = Palette.building.withAlphaComponent(0.85)
subtitle.verticalAlignmentMode = .top
subtitle.position = CGPoint(x: 0, y: -27)
addChild(subtitle)
}
required init?(coder: NSCoder) { fatalError() }
func update(subtitle text: String) {
if subtitle.text != text { subtitle.text = text }
}
var knownHostCount: Int { knownHosts.count }
/// The label box (title + subtitle) relative to this node's center, used for
/// collision-aware placement so labels don't overlap their neighbours.
var labelFootprint: CGRect {
title.calculateAccumulatedFrame().union(subtitle.calculateAccumulatedFrame())
}
func note(activeHosts: [String]) { knownHosts.formUnion(activeHosts) }
/// React to a tick of traffic: bump the (decaying) activity level, grow
/// toward a size set by it, and pulse so the eye catches the activity.
func receive(bytes: Double) {
activity += bytes
updateGlowSize()
core.removeAction(forKey: "pulse")
core.run(.sequence([
.scale(to: 1.9, duration: 0.12),
.scale(to: 1.0, duration: 0.35),
]), withKey: "pulse")
}
/// Called every tick. Activity decays so quiet hubs visibly shrink toward
/// dormant instead of staying bright forever.
func decay(factor: Double = 0.55) {
guard activity > 0 else { return }
activity *= factor
updateGlowSize()
}
/// Ticks since this hub last carried traffic used to reap dead districts.
var idleTicks = 0
private func updateGlowSize() {
let target = (0.20 + CGFloat(log10(activity + 1)) * 0.085).clamped(0.20, 1.05)
glow.removeAction(forKey: "resize")
glow.run(.scale(to: target, duration: 0.6), withKey: "resize")
}
// MARK: - Expansion
/// The capped, stably-ordered list of endpoints we actually draw.
private var renderedHosts: [String] { knownHosts.sorted().prefix(maxEndpoints).map { $0 } }
/// Local offset of an endpoint, fanned outward (away from downtown at origin).
private func offset(forIndex i: Int, count: Int, host: String) -> CGPoint {
let outward = atan2(position.y, position.x)
let angle: CGFloat
if count <= 1 {
angle = outward
} else {
let t = CGFloat(i) / CGFloat(count - 1) // 01 across the arc
angle = outward - fanSpread + 2 * fanSpread * t
}
let depth = spokeRadius + CGFloat(stableHash(host) % 4) * 11
return CGPoint(x: cos(angle) * depth, y: sin(angle) * depth)
}
/// Scene-space position of an endpoint (for routing cars along its spoke).
func worldPosition(forEndpoint host: String) -> CGPoint {
let hosts = renderedHosts
guard let i = hosts.firstIndex(of: host) else { return position }
let o = offset(forIndex: i, count: hosts.count, host: host)
return CGPoint(x: position.x + o.x, y: position.y + o.y)
}
func setExpanded(_ on: Bool) {
if on {
if !isExpanded {
isExpanded = true
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
FileHandle.standardError.write(Data("[expand] \(title.text ?? "?") fanned out → \(knownHostCount) endpoints\n".utf8))
}
endpointLayer.alpha = 0
spokeLayer.alpha = 0
endpointLayer.run(.fadeIn(withDuration: 0.4))
spokeLayer.run(.fadeAlpha(to: 0.5, duration: 0.4))
}
layoutEndpoints()
} else if isExpanded {
isExpanded = false
endpointLayer.run(.fadeOut(withDuration: 0.35))
spokeLayer.run(.sequence([.fadeOut(withDuration: 0.35), .run { [weak self] in
self?.spokeLayer.removeAllChildren()
self?.endpointLayer.removeAllChildren()
self?.endpointNodes.removeAll()
}]))
}
}
func pulseEndpoint(_ host: String) {
endpointNodes[host]?.pulse()
}
private func layoutEndpoints() {
let hosts = renderedHosts
spokeLayer.removeAllChildren()
let spokes = CGMutablePath()
for (i, host) in hosts.enumerated() {
let target = offset(forIndex: i, count: hosts.count, host: host)
let node = endpointNodes[host] ?? {
let n = EndpointNode()
n.position = .zero
n.alpha = 0
n.run(.fadeIn(withDuration: 0.3))
endpointLayer.addChild(n)
endpointNodes[host] = n
return n
}()
node.run(.move(to: target, duration: 0.3))
spokes.move(to: .zero)
spokes.addLine(to: target)
}
// drop endpoints that fell out of the capped set
for (host, node) in endpointNodes where !hosts.contains(host) {
node.removeFromParent()
endpointNodes[host] = nil
}
let line = SKShapeNode(path: spokes)
line.strokeColor = Palette.building.withAlphaComponent(0.4)
line.lineWidth = 1
spokeLayer.addChild(line)
}
}