These are script-only files (no scene). Create them in res://Autoload/ then register them in Project Settings → Autoload.
Open Project → Project Settings → Autoload and add these three:
| Path | Name |
|---|---|
| res://Autoload/SaveManager.gd | SaveManager |
| res://Autoload/AudioManager.gd | AudioManager |
| res://Autoload/PuzzleGenerator.gd | PuzzleGenerator |
extends Node
const SAVE_FILE := "user://equi_progress.cfg"
const SECTION := "progress"
var tier: int = 0
var puzzle: int = 0
var has_save: bool = false
func _ready() -> void: load_game()
func has_progress() -> bool: return has_save
func save_game() -> void:
var cfg := ConfigFile.new()
cfg.set_value(SECTION, "tier", tier)
cfg.set_value(SECTION, "puzzle", puzzle)
cfg.save(SAVE_FILE)
has_save = tier > 0 or puzzle > 0
func load_game() -> void:
var cfg := ConfigFile.new()
if cfg.load(SAVE_FILE) != OK: tier=0; puzzle=0; has_save=false; return
tier = cfg.get_value(SECTION,"tier",0)
puzzle = cfg.get_value(SECTION,"puzzle",0)
has_save = tier > 0 or puzzle > 0
func reset_progress() -> void:
tier=0; puzzle=0; has_save=false
var d := DirAccess.open("user://")
if d: d.remove("equi_progress.cfg")
extends Node func play_click() -> void: _tone(800, 0.05, 0.08) func play_violate() -> void: _tone(150, 0.2, 0.04, "sawtooth") func play_hint() -> void: _tone(880, 0.1, 0.06, "triangle") func play_menu_select() -> void: _tone(660, 0.08, 0.06) func play_complete() -> void: _tone(523, 0.4, 0.1); await get_tree().create_timer(0.15).timeout _tone(659, 0.4, 0.1); await get_tree().create_timer(0.15).timeout _tone(784, 0.4, 0.1); await get_tree().create_timer(0.15).timeout _tone(1047, 0.4, 0.1) func _tone(f: float, dur: float, vol: float, wave: String = "sine") -> void: var p := AudioStreamPlayer2D.new(); add_child(p) var g := AudioStreamGenerator.new(); g.mix_rate=22050; g.buffer_length=max(dur+0.05,0.1) p.stream = g; var pb := p.get_stream_playback() if not pb: p.queue_free(); return p.play(); var n: int = int(g.mix_rate*dur) var b: PackedVector2Array = []; b.resize(n) for i in n: var t: float = float(i)/g.mix_rate var e: float = clamp((1.0-float(i)/n)*(1.0-float(i)/n)*3,0,1) var s: float match wave: "sine": s = sin(t*f*TAU) "sawtooth": s = 2*(t*f-floor(t*f+0.5)) "triangle": s = 2*abs(2*(t*f-floor(t*f+0.5)))-1 b[i] = Vector2(s*e*0.3, s*e*0.3) var _ = pb.push_buffer(b) await get_tree().create_timer(dur+0.05).timeout; p.queue_free()
Procedural graph generation: random point placement + kNN connectivity + MST for tree tiers + spring relaxation. Poisson disc removed — spring relaxation handles layout naturally.
extends Node
class PPoint:
var x: float; var y: float
func _init(x_:float,y_:float): x=x_; y=y_
static func knn_graph(pts: Array[PPoint], k: int = 3) -> Array[Array]:
var edges: Array[Array] = []
for i in pts.size():
var ds: Array = []
for j in pts.size():
if i==j: continue
ds.append({"d": hypot(pts[i].x-pts[j].x,pts[i].y-pts[j].y), "j": j})
ds.sort_custom(func(a,b): return a.d=k: break
var j: int = ds[n].j
if j>i:
var ex: bool = false
for ei in edges:
if (ei[0]==i and ei[1]==j) or (ei[0]==j and ei[1]==i): ex=true; break
if not ex: edges.append([i,j]); added+=1
return edges
static func _uf(parent: Array[int], x: int) -> int:
while parent[x]!=x: parent[x]=parent[parent[x]]; x=parent[x]
return x
static func _un(parent: Array[int], a:int, b:int) -> void:
parent[_uf(parent,a)]=_uf(parent,b)
static func ensure_connected(pts: Array[PPoint], edges: Array[Array]) -> Array[Array]:
var n:=pts.size(); if n<2: return edges
var p: Array[int]=[]; p.resize(n)
for i in n: p[i]=i
for e in edges: _un(p,e[0],e[1])
var comps: Dictionary = {}
for i in n:
var r:=_uf(p,i)
if not comps.has(r): comps[r]=[]
comps[r].append(i)
if comps.size()<=1: return edges
var cl: Array = comps.values()
for ci in cl.size()-1:
var bd:=INF; var be: Array=[]
for a in cl[ci]: for b in cl[ci+1]:
var d:=hypot(pts[a].x-pts[b].x,pts[a].y-pts[b].y)
if d0: edges.append(be); _un(p,be[0],be[1])
var c2: Dictionary={}
for i in n:
var r:=_uf(p,i)
if not c2.has(r): c2[r]=[]
c2[r].append(i)
var cl2: Array=c2.values()
for ci in cl2.size()-1:
var ra:=_uf(p,cl2[ci][0]); var rb:=_uf(p,cl2[ci+1][0])
if ra!=rb:
var bd:=INF; var be: Array=[]
for a in cl2[ci]: for b in cl2[ci+1]:
var d:=hypot(pts[a].x-pts[b].x,pts[a].y-pts[b].y)
if d0: edges.append(be); _un(p,be[0],be[1])
return edges
static func minimum_spanning_tree(pts: Array[PPoint], edges: Array[Array]) -> Array[Array]:
var n:=pts.size(); var p: Array[int]=[]; p.resize(n)
for i in n: p[i]=i
var s: Array=[]; var mst: Array[Array]=[]
for e in edges:
s.append({"a":e[0],"b":e[1],"d":hypot(pts[e[0]].x-pts[e[1]].x,pts[e[0]].y-pts[e[1]].y)})
s.sort_custom(func(a,b): return a.d void:
var pad:=80.0; var ideal:=min(w,h)*0.18
var rep:=2500.0; var stf:=0.08; var damp:=0.6; var n:=nodes.size()
var vx: Array[float]=[]; var vy: Array[float]=[]; vx.resize(n); vy.resize(n)
var adj: Array[Array]=[]; adj.resize(n)
for i in n: adj[i]=[]
if n>0 and nodes[0].has("_edges"):
for e in nodes[0]._edges: adj[e.from].append(e.to); adj[e.to].append(e.from)
for it in iters:
var tmp:=1.0-float(it)/iters; var cx:=w/2; var cy:=h/2
for i in n:
var fx:=0.0; var fy:=0.0; var nx:=nodes[i].x; var ny:=nodes[i].y
for j in n:
if i==j: continue
var dx:=nodes[j].x-nx; var dy:=nodes[j].y-ny
var d:=max(hypot(dx,dy),1.0); var f:=rep/(d*d)
fx-=dx/d*f; fy-=dy/d*f
fx+=(cx-nx)*0.001; fy+=(cy-ny)*0.001
for nb in adj[i]:
var dx:=nodes[nb].x-nx; var dy:=nodes[nb].y-ny
var d:=max(hypot(dx,dy),1.0); var f:=(d-ideal)*stf
fx+=dx/d*f; fy+=dy/d*f
vx[i]=(vx[i]+fx)*damp; vy[i]=(vy[i]+fy)*damp
nodes[i].x+=vx[i]*tmp; nodes[i].y+=vy[i]*tmp
nodes[i].x=clamp(nodes[i].x,pad,w-pad); nodes[i].y=clamp(nodes[i].y,pad,h-pad)
static func generate_puzzle(width:float,height:float,opts:Dictionary={})->Dictionary:
var nc:=opts.get("node_count",randi_range(5,8)); var vmax:=opts.get("value_max",6)
var tree:=opts.get("tree_only",true); var lock:=opts.get("lock_ratio",0.5)
var pad:=80.0; var pw:=width-pad*2; var ph:=height-pad*2
# Random points — spring relaxation will spread them
var pts: Array[PPoint] = []
for i in nc*3: pts.append(PPoint.new(randf_range(0,pw),randf_range(0,ph)))
var sel: Array[PPoint] = []
for i in min(nc,pts.size()): sel.append(PPoint.new(pts[i].x+pad,pts[i].y+pad))
while sel.size()nj: ni=e[1]; nj=e[0]
var tgt:=absi(nodes[ni].value-nodes[nj].value)
var edge: Dictionary={"from":ni,"to":nj,"type":"Delta","target":tgt,"state":"neutral","anim_phase":randf_range(0,TAU)}
edges.append(edge)
if not nodes[ni].has("_edges") or nodes[ni]._edges==null: nodes[ni]._edges=[]
nodes[ni]._edges.append(edge); nodes[nj]._edges.append(edge)
var deg: Array[int]=[]; deg.resize(nodes.size())
for e in edges: deg[e.from]+=1; deg[e.to]+=1
var nn:=nodes.size(); var tgt_l:=max(1,min(int(nn*lock),nn-1))
var sel2: Array[int]=[]; var mx:=0; var fi:=0
for i in nn: if deg[i]>mx: mx=deg[i]; fi=i
sel2.append(fi)
var cand: Array[int]=[]
for i in nn: if not sel2.has(i): cand.append(i)
cand.sort_custom(func(a,b): return deg[b]-deg[a])
var st:=max(1,int(cand.size()/max(1,tgt_l-sel2.size()+1)))
for i in range(0,cand.size(),st):
if sel2.size()>=tgt_l: break
if not sel2.has(cand[i]): sel2.append(cand[i])
for e in edges:
if sel2.has(e.from) and sel2.has(e.to):
if randf()>0.5: sel2.erase(e.from)
else: sel2.erase(e.to)
for idx in sel2: nodes[idx].locked=true; nodes[idx].guess=nodes[idx].value
return {"nodes":nodes,"edges":edges,"value_max":vmax}
static func hypot(a:float,b:float)->float: return sqrt(a*a+b*b)
static func clamp(v:float,mn:float,mx:float)->float: return max(mn,min(mx,v))
Root node: Area2D → name "Planet" → attach script res://Scripts/Planet.gd
Children to add:
| # | Node type | Name | Key properties |
|---|---|---|---|
| 1 | CollisionShape2D | CollisionShape2D | Shape: CircleShape2D, radius 30 |
| 2 | Label | NodeLabel | text "#0", offset (-15,24)-(15,38), h_align center, font_size 10 |
| 3 | Label | QMark | text "?", offset (-10,-10)-(10,10), h+v_align center, font_size 18 |
No LockLabel — locked planets are now distinguished by an orbital ring drawn in code. QMark starts visible and is hidden when the player sets a moon count.
# Planet — a single node in the constellation # Tap to cycle moons. Locked planets show an orbital ring. class_name Planet extends Area2D signal tapped(planet: Planet) var idx: int = -1 var value: int = 0 var locked: bool = false var guess: int = -1 # -1 = unset var is_hint_target: bool = false var _anim_phase: float = 0.0 var _planet_radius: float = 22.0 @onready var node_label: Label = %NodeLabel @onready var qmark_label: Label = %QMark func _ready() -> void: _anim_phase = randf_range(0, TAU) input_event.connect(_on_input) func _on_input(_viewport: Node, event: InputEvent, _shape_idx: int) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: tapped.emit(self) get_viewport().set_input_as_handled() func setup(index: int, val: int, is_locked: bool) -> void: idx = index; value = val; locked = is_locked name = "Planet_%d" % idx node_label.text = "#%d" % (idx + 1) refresh(-1) func refresh(g: int = -1, hint: bool = false) -> void: guess = g; is_hint_target = hint qmark_label.visible = (guess < 0 and not locked) queue_redraw() func _draw() -> void: var r: float = _planet_radius + (4.0 if locked else 0.0) var pos := Vector2.ZERO var time: float = Time.get_ticks_usec() * 0.000001 # Glow var gr: float = r + 12.0 + 4.0 * sin(time * 1.5 + _anim_phase) var glow: Color if is_hint_target: glow = Color(0.976,0.792,0.141,0.15) elif locked: glow = Color(0.424,0.388,1.0,0.12) elif guess >= 0: glow = Color(0.0,0.902,0.463,0.08) else: glow = Color(1,1,1,0.04) draw_circle(pos, gr + 10.0, glow) # Body var fill: Color; var stroke: Color; var sw: float if locked: fill = Color(0.165,0.102,0.29); stroke = Color(0.424,0.388,1.0,0.6) elif is_hint_target: fill = Color(0.165,0.165,0.102); stroke = Color(0.976,0.792,0.141); sw = 2.5 elif guess >= 0: fill = Color(0.102,0.102,0.227); stroke = Color(0.784,0.745,1.0,0.6); sw = 1.5 else: fill = Color(0.055,0.055,0.133); stroke = Color(0.784,0.745,1.0,0.3); sw = 1.5 draw_circle(pos, r, fill) draw_arc(pos, r, 0, TAU, 64, stroke, sw, false) # Orbital ring for locked planets (Saturn-style) if locked: var rc := Color(0.424, 0.388, 1.0, 0.35) draw_arc(pos, r*1.9, PI*0.1, PI*0.9, 32, rc, 2.5, false) draw_arc(pos, r*1.9, -PI*0.9, -PI*0.1, 32, rc, 2.5, false) draw_arc(pos, r*2.2, PI*0.15, PI*0.85, 32, Color(0.424,0.388,1.0,0.2), 1.5, false) draw_arc(pos, r*2.2, -PI*0.85, -PI*0.15, 32, Color(0.424,0.388,1.0,0.2), 1.5, false) # Hint ring if is_hint_target: var da := 0.3 + 0.3 * sin(time * 4.0) draw_arc(pos, r + 8.0, 0, TAU, 64, Color(0.976,0.792,0.141,da), 3.0, true) # Moons var mc: int = value if locked else (guess if guess >= 0 else 0) if mc > 0: var mt: float = time * 0.8; var ms: float = 3.5 if mc <= 4 else 2.5 for m in mc: var a: float = float(m)/mc * TAU + mt + idx * 1.5 var mr: float = r + 10.0 + 4.0 * sin(time*0.3 + idx + m) var mx := cos(a)*mr; var my := sin(a)*mr var mc2 := Color(0.941,0.902,0.549,0.3) if locked else Color(0.941,0.902,0.549,0.8) draw_circle(Vector2(mx,my), ms+2.0, Color(0.941,0.902,0.549,0.15)) draw_circle(Vector2(mx,my), ms, mc2)
Root node: Line2D → name "Starlane" → attach script res://Scripts/Starlane.gd
Set default_color rgba(100,100,160,0.3), width 1.5
Children:
| # | Node type | Name | Key properties |
|---|---|---|---|
| 1 | Label | ConstraintLabel | text "D0", offset (-25,-20)-(25,0), h_align center, font_size 14 |
| 2 | Label | SubLabel | text "abs diff", offset (-30,0)-(30,16), h_align center, font_size 10 |
# Starlane — a constraint edge between two planets
class_name Starlane extends Line2D
var from_idx: int = -1
var to_idx: int = -1
var target_value: int = 0
var state: String = "neutral" # neutral, waiting, satisfied, violated
var _anim_phase: float = 0.0
@onready var constraint_label: Label = %ConstraintLabel
@onready var sub_label: Label = %SubLabel
func _ready() -> void: _anim_phase = randf_range(0, TAU)
func setup(frm:int, to:int, target:int, type_str:String="Delta") -> void:
from_idx=frm; to_idx=to; target_value=target
name="Starlane_%d_%d"%[frm,to]
constraint_label.text=type_str+str(target)
set_state("neutral")
func set_state(s: String) -> void:
state=s
match s:
"satisfied": default_color=Color(0,0.902,0.463); width=3; constraint_label.theme_override_colors/font_color=Color(0,0.902,0.463)
"violated": default_color=Color(1,0.09,0.267); width=2.5; constraint_label.theme_override_colors/font_color=Color(1,0.09,0.267)
"waiting": default_color=Color(0.392,0.392,0.627,0.5); width=1.5; constraint_label.theme_override_colors/font_color=Color(0.588,0.588,0.784,0.5)
_: default_color=Color(0.392,0.392,0.627,0.3); width=1.5; constraint_label.theme_override_colors/font_color=Color(0.588,0.588,0.784,0.5)
func update_endpoints(from:Vector2, to:Vector2) -> void:
clear_points(); add_point(from); add_point(to)
var mid:=(from+to)*0.5
constraint_label.position=Vector2(mid.x-25,mid.y-20)
sub_label.position=Vector2(mid.x-25,mid.y+2)
Root node: Node2D → name "PuzzleBoard" → attach script res://Scripts/PuzzleBoard.gd
No children needed. The script creates Planet and Starlane instances at runtime.
# PuzzleBoard — orchestrates the puzzle
class_name PuzzleBoard extends Node2D
signal puzzle_solved; signal toast_requested(msg: String)
var nodes_data: Array[Dictionary] = []
var edges_data: Array[Dictionary] = []
var value_max: int = 6
var _planets: Array[Planet] = []; var _starlanes: Array[Starlane] = []
var _undo_stack: Array[Dictionary] = []
var _hints_used:=0; var _max_hints:=3; var _hint_target_idx:=-1
var _solved:=false; var _animating:=false; var _dialog_pending:=false
var _completion_time:=0.0; var _center:=Vector2.ZERO
var _particles: Array[Dictionary] = []; var _view_size:=Vector2(1280,720)
const PC:=80
func load_puzzle(data:Dictionary,view_w:float,view_h:float)->void:
clear_all()
nodes_data=data.nodes; edges_data=data.edges; value_max=data.value_max
_view_size=Vector2(view_w,view_h); _solved=false; _animating=false; _dialog_pending=false
_undo_stack.clear(); _hints_used=0; _hint_target_idx=-1; _particles.clear()
_center=Vector2.ZERO
for n in nodes_data: _center+=Vector2(n.x,n.y)
_center/=max(nodes_data.size(),1)
PuzzleGenerator.relax_layout(nodes_data,view_w,view_h,100)
for i in nodes_data.size():
var n:=nodes_data[i]; var p:Planet=preload("res://Scenes/Planet.tscn").instantiate()
p.position=Vector2(n.x,n.y); p.setup(i,n.value,n.locked)
if not n.locked: p.guess=-1; p.refresh(-1)
p.tapped.connect(_on_planet_tapped); add_child(p); _planets.append(p)
for e in edges_data:
var l:Starlane=preload("res://Scenes/Starlane.tscn").instantiate()
l.setup(e.from,e.to,e.target,e.type)
l.update_endpoints(Vector2(nodes_data[e.from].x,nodes_data[e.from].y),Vector2(nodes_data[e.to].x,nodes_data[e.to].y))
add_child(l); _starlanes.append(l)
validate_all()
func clear_all()->void:
for p in _planets: if is_instance_valid(p): p.queue_free()
for s in _starlanes: if is_instance_valid(s): s.queue_free()
_planets.clear(); _starlanes.clear()
func _on_planet_tapped(p:Planet)->void:
if _solved or _animating or p.locked: return
AudioManager.play_click()
_undo_stack.append({"planet":p,"prev":p.guess})
if p.guess<0: p.guess=0
elif p.guess>=value_max: p.guess=-1
else: p.guess+=1
p.refresh(p.guess); validate_all()
func validate_all()->void:
var filled:=true
for p in _planets: if p.guess<0 and not p.locked: filled=false; break
if not filled:
for i in edges_data.size():
var e:=edges_data[i]; var l:=_starlanes[i]
var as:=(_planets[e.from].guess>=0)or _planets[e.from].locked
var bs:=(_planets[e.to].guess>=0)or _planets[e.to].locked
var av:=_planets[e.from].value if _planets[e.from].locked else _planets[e.from].guess
var bv:=_planets[e.to].value if _planets[e.to].locked else _planets[e.to].guess
if as and bs: l.set_state("satisfied" if abs(av-bv)==e.target else "violated")
elif as or bs: l.set_state("waiting"); else: l.set_state("neutral")
return
var ok:=true
for i in edges_data.size():
var e:=edges_data[i]; var av:=_planets[e.from].value if _planets[e.from].locked else _planets[e.from].guess
var bv:=_planets[e.to].value if _planets[e.to].locked else _planets[e.to].guess
if abs(av-bv)!=e.target: ok=false; break
if ok and not _solved:
for l in _starlanes: l.set_state("satisfied"); _start_completion()
elif not ok:
for i in edges_data.size():
var e:=edges_data[i]; var l:=_starlanes[i]
var av:=_planets[e.from].value if _planets[e.from].locked else _planets[e.from].guess
var bv:=_planets[e.to].value if _planets[e.to].locked else _planets[e.to].guess
l.set_state("satisfied" if abs(av-bv)==e.target else "violated")
AudioManager.play_violate()
await get_tree().create_timer(1.5).timeout
if not _solved and _undo_stack.size()>0:
var last:=_undo_stack.pop_back()
last.planet.guess=-1; last.planet.refresh(-1); validate_all()
func _start_completion()->void:
_solved=true; _animating=true; _completion_time=Time.get_ticks_msec()/1000.0
AudioManager.play_complete()
for i in PC:
_particles.append({"x":randf_range(0,_view_size.x),"y":randf_range(0,_view_size.y),
"vx":randf_range(-2,2),"vy":randf_range(-2,2),"size":randf_range(1,3),
"phase":randf_range(0,TAU),
"color":[Color(0.424,0.388,1),Color(0,0.902,0.463),Color(0.976,0.792,0.141),Color(1,0.42,0.616)][randi()%4]})
func _process(delta:float)->void:
var t:=Time.get_ticks_msec()/1000.0
for p in _planets:
if p.guess<0 and not p.locked:
p.qmark_label.theme_override_colors/font_color=Color(1,1,1,0.3+0.2*sin(t*2+p.idx*1.7))
for i in _starlanes.size():
if i0.5:
var ease:=clamp((el-0.5)/2,0,1); ease=ease*ease*(3-2*ease)
if ease<1:
for i in _planets.size(): _planets[i].position=_planets[i].position.lerp(_center,ease*delta*3)
if el>=3 and not _dialog_pending: _dialog_pending=true; _animating=false; puzzle_solved.emit()
func undo()->void:
if _undo_stack.size()==0 or _solved: return
var last:=_undo_stack.pop_back()
last.planet.guess=-1; last.planet.refresh(-1); validate_all()
func reset_puzzle()->void:
_undo_stack.clear(); _solved=false; _animating=false; _dialog_pending=false
_hints_used=0; _hint_target_idx=-1; _particles.clear()
for p in _planets: if not p.locked: p.guess=-1; p.refresh(-1)
for s in _starlanes: s.set_state("neutral")
AudioManager.play_menu_select()
func use_hint()->void:
if _solved or _animating: return
if _hints_used>=_max_hints: toast_requested.emit("No hints remaining"); return
AudioManager.play_hint(); _hints_used+=1
var unset: Array[int]=[]
for i in _planets.size(): if _planets[i].guess<0 and not _planets[i].locked: unset.append(i)
if unset.size()==0: toast_requested.emit("All planets filled"); return
var best:=unset[0]; var score:=-1
for idx in unset:
var s:=0
for e in edges_data:
var o:=e.to if e.from==idx else e.from
if _planets[o].guess>=0 or _planets[o].locked: s+=1
if s>score: score=s; best=idx
_hint_target_idx=best; _planets[best].is_hint_target=true; _planets[best].queue_redraw()
toast_requested.emit("Check constraints around planet #%d"%(best+1))
await get_tree().create_timer(2.5).timeout
if is_instance_valid(self) and best<_planets.size():
_hint_target_idx=-1; _planets[best].is_hint_target=false; _planets[best].queue_redraw()
Root: CanvasLayer → name "HUD", layer 2, attach script res://Scripts/HUD.gd
| Child | Type | Properties |
|---|---|---|
| Bg | ColorRect | anchor fill screen, color rgba(0,0,0,0.35), ignore mouse |
| MenuBtn | Button | text "x", flat, pos (12,6)-(52,50), font_size 18, color rgba(1,1,1,0.7) |
| Title | Label | text "Tier 1 . Puzzle 1", pos (64,4)-(300,30), font_size 11 |
| Subtitle | Label | pos (64,28)-(300,52), font_size 9 |
| UndoBtn | Button | text "Undo", flat, anchor L:0.75 R:0.82, T:6 B:50, font_size 14 |
| ResetBtn | Button | text "Reset", flat, anchor L:0.84 R:0.91, T:6 B:50, font_size 14 |
| HintBtn | Button | text "?", flat, anchor L:0.93 R:1.0, T:6 B:50, font_size 18 |
| Progress | Label | text "1 / 8", h_align center, anchor T:0.92 B:0.98, font_size 10, ignore mouse |
class_name HUD extends CanvasLayer signal menu_pressed; signal undo_pressed; signal reset_pressed; signal hint_pressed @onready var title_label:Label=%Title; @onready var subtitle_label:Label=%Subtitle @onready var progress_label:Label=%Progress; @onready var hint_btn:Button=%HintBtn func _ready()->void: %MenuBtn.pressed.connect(func():menu_pressed.emit()) %UndoBtn.pressed.connect(func():undo_pressed.emit()) %ResetBtn.pressed.connect(func():reset_pressed.emit()) %HintBtn.pressed.connect(func():hint_pressed.emit()) func set_puzzle_info(tn:int,pn:int,name:String,total:int)->void: title_label.text="Tier %d . Puzzle %d"%[tn+1,pn+1]; subtitle_label.text=name progress_label.text="%d / %d"%[pn+1,total] func set_hint_button_state(used:int,maxh:int)->void: hint_btn.theme_override_colors/font_color=Color(1,1,1,0.4 if maxh-used<=0 else 0.7)
Root: CanvasLayer → name "MenuScreen", layer 3, script res://Scripts/MenuScreen.gd
| Child | Type | Properties |
|---|---|---|
| Bg | ColorRect | anchor fill screen, color rgba(0.027,0.027,0.059,0.8), ignore mouse |
| LogoSymbol | Label | text "=", anchor L:0 R:1 T:0.18 B:0.45, h+v_align center, font_size 80, color #6c63ff |
| NameLabel | Label | text "EQUI", anchor L:0 R:1 T:0.4 B:0.5, h+v_align center, font_size 24 |
| Tagline | Label | text "Deductive Graph Puzzle", anchor L:0 R:1 T:0.46 B:0.55, font_size 12 |
| NewGameBtn | Button | text "NEW GAME", flat, anchor L:0.35 R:0.65 T:0.58 B:0.65, font_size 14 |
| ContinueBtn | Button | text "CONTINUE", flat, anchor L:0.35 R:0.65 T:0.67 B:0.73, font_size 12, starts hidden |
| Hint | Label | text "Tap planets - Place moons - Satisfy all Delta constraints", anchor L:0 R:1 T:0.82 B:0.88, font_size 10, ignore mouse |
class_name MenuScreen extends CanvasLayer signal new_game_pressed; signal continue_pressed @onready var continue_btn:Button=%ContinueBtn func _ready()->void: %NewGameBtn.pressed.connect(func():new_game_pressed.emit()) %ContinueBtn.pressed.connect(func():continue_pressed.emit()) func refresh_continue(has:bool)->void: continue_btn.visible=has
Root: CanvasLayer → name "TierIntro", layer 4, script res://Scripts/TierIntro.gd
| Child | Type | Properties |
|---|---|---|
| Bg | ColorRect | anchor fill, color rgba(0,0,0,0.85), block mouse |
| Card | ColorRect | anchor L:0.25 R:0.75 T:0.28 B:0.72, color #101022 |
| TierNum | Label | text "TIER I", anchor L:0.25 R:0.75 T:0.3 B:0.36, h+v_align center, font_size 16, color #6c63ff |
| TierName | Label | anchor L:0.25 R:0.75 T:0.36 B:0.44, font_size 26, color white |
| Desc | Label | anchor L:0.3 R:0.7 T:0.46 B:0.56, font_size 11, word wrap |
| Difficulty | Label | anchor L:0.25 R:0.75 T:0.58 B:0.63, h_align center, font_size 12 |
| BeginBtn | Button | text "BEGIN", flat, anchor L:0.38 R:0.62 T:0.64 B:0.7, font_size 14 |
class_name TierIntro extends CanvasLayer
signal begin_pressed
@onready var tn:Label=%TierNum; @onready var nm:Label=%TierName
@onready var desc:Label=%Desc; @onready var diff:Label=%Difficulty
const ROM:=["I","II","III","IV","V","VI"]
const DC:Dictionary={"Easy":Color(0,0.902,0.463),"Medium":Color(0.976,0.792,0.141),
"Hard":Color(1,0.42,0.616),"Expert":Color(1,0.09,0.267)}
func _ready()->void: %BeginBtn.pressed.connect(func():begin_pressed.emit())
func show_for(ti:int,name:String,d:String,diff_s:String)->void:
tn.text="TIER "+(ROM[ti] if ti
Root: CanvasLayer → name "DialogOverlay", layer 5, script res://Scripts/DialogOverlay.gd
| Child | Type | Properties |
|---|---|---|
| Bg | ColorRect | anchor fill, color rgba(0,0,0,0.6), block mouse |
| Card | ColorRect | anchor L:0.28 R:0.72 T:0.32 B:0.68, color #101022 |
| Icon | Label | anchor same as Card, T:0.33 B:0.4, h+v_align center, font_size 36, color gold |
| Title | Label | anchor L:0.28 R:0.72 T:0.4 B:0.48, font_size 18 |
| Text | Label | anchor L:0.3 R:0.7 T:0.48 B:0.56, font_size 11, word wrap |
| BtnContainer | HBoxContainer | anchor L:0.3 R:0.7 T:0.58 B:0.66, center alignment |
class_name DialogOverlay extends CanvasLayer
signal dismissed
@onready var icon:Label=%Icon; @onready var title:Label=%Title
@onready var text:Label=%Text; @onready var btns:HBoxContainer=%BtnContainer
func _ready()->void: visible=false
func show_dialog(ic:String,ti:String,tx:String,buttons:Array[Dictionary])->void:
icon.text=ic; title.text=ti; text.text=tx
for c in btns.get_children(): c.queue_free()
for bd in buttons:
var b:=Button.new(); b.text=bd.get("text","OK"); b.flat=true
b.size_flags_horizontal=Control.SIZE_SHRINK_CENTER
b.theme_override_font_sizes/font_size=12
b.theme_override_colors/font_color=bd.get("color",Color(0.753,0.749,1))
b.pressed.connect(bd.get("action",func():dismiss())); btns.add_child(b)
visible=true
func dismiss()->void: visible=false; dismissed.emit()
Root: Node → name "Main" → attach script res://Scripts/Main.gd
Instance the following scenes as children (right-click Root → Instance Child Scene):
| Instanced scene | Node name | Initially visible? |
|---|---|---|
| res://Scenes/MenuScreen.tscn | MenuScreen | Yes |
| res://Scenes/HUD.tscn | HUD | No |
| res://Scenes/PuzzleBoard.tscn | PuzzleBoard | No |
| res://Scenes/TierIntro.tscn | TierIntro | No |
| res://Scenes/DialogOverlay.tscn | DialogOverlay | No |
Then add a CanvasLayer child named "ToastLayer", layer 6. Inside it, add a Label named "Toast" with:
class_name Main extends Node
enum State{SPLASH,TIER_INTRO,PLAYING,DIALOG}
var state:=State.SPLASH; var tier:=0; var puzzle:=0
const PPT:=8; var _td:Dictionary={}; var _tw:Tween
@onready var ms:MenuScreen=%MenuScreen; @onready var hud:HUD=%HUD
@onready var pb:PuzzleBoard=%PuzzleBoard; @onready var ti:TierIntro=%TierIntro
@onready var dlg:DialogOverlay=%DialogOverlay; @onready var toast:Label=%Toast
const TIERS:=[
{"name":"Void Garden","desc":"Delta on tree graphs.","tree":true,"vmax":3,"lock":0.45,"diff":"Easy","base":4},
{"name":"Echo Orbits","desc":"Delta with cycles.","tree":false,"vmax":4,"lock":0.40,"diff":"Medium","base":6},
{"name":"Binary Flares","desc":"Mixed Delta and larger graphs.","tree":false,"vmax":5,"lock":0.38,"diff":"Medium","base":7},
{"name":"Wrap Nebula","desc":"Tight Delta at higher values.","tree":false,"vmax":6,"lock":0.35,"diff":"Hard","base":9},
{"name":"Dual Spectra","desc":"Complex graphs with high values.","tree":false,"vmax":6,"lock":0.32,"diff":"Hard","base":11},
{"name":"Singularity","desc":"Expert puzzles. Minimal locked planets.","tree":false,"vmax":6,"lock":0.28,"diff":"Expert","base":13},
]
func _ready()->void:
ms.new_game_pressed.connect(_on_new_game)
ms.continue_pressed.connect(_on_continue)
hud.menu_pressed.connect(_on_menu); hud.undo_pressed.connect(_on_undo)
hud.reset_pressed.connect(_on_reset); hud.hint_pressed.connect(_on_hint)
ti.begin_pressed.connect(_on_begin_tier)
pb.puzzle_solved.connect(_on_puzzle_solved)
pb.toast_requested.connect(_show_toast)
enter_splash()
func enter_splash()->void:
state=State.SPLASH; pb.visible=false; hud.visible=false
ti.visible=false; dlg.visible=false; ms.visible=true
ms.refresh_continue(SaveManager.has_progress())
func _on_new_game()->void:
SaveManager.reset_progress(); tier=0; puzzle=0; _gen(); show_tier_intro()
func _on_continue()->void:
tier=SaveManager.tier; puzzle=SaveManager.puzzle; _gen(); show_tier_intro()
func _gen()->void: _td={"puzzles":_gen_puzzles()}; _td.merge(TIERS[tier],true)
func _gen_puzzles()->Array:
var td:=TIERS[tier]; var vp:=get_viewport_rect(); var r:Array=[]
for i in PPT:
var nc:=min(td.base+i,16 if tier<=3 else 20)
var d:=PuzzleGenerator.generate_puzzle(vp.size.x,vp.size.y,{"node_count":nc,"value_max":td.vmax,"tree_only":td.tree,"lock_ratio":td.lock+i*0.01})
if d.nodes.size()>=3: r.append(d)
return r
func show_tier_intro()->void:
state=State.TIER_INTRO; ms.visible=false; hud.visible=false
pb.visible=false; dlg.visible=false
ti.show_for(tier,TIERS[tier].name,TIERS[tier].desc,TIERS[tier].diff)
func _on_begin_tier()->void: ti.visible=false; puzzle=0; hud.visible=true; _load(0)
func _load(idx:int)->void:
if idx>=_td.puzzles.size(): _tier_complete(); return
state=State.PLAYING; puzzle=idx; pb.visible=true
pb.load_puzzle(_td.puzzles[idx],get_viewport_rect().size.x,get_viewport_rect().size.y)
hud.set_puzzle_info(tier,idx,TIERS[tier].name,PPT); hud.set_hint_button_state(0,3)
func _on_puzzle_solved()->void:
state=State.DIALOG; SaveManager.save_game()
var last:=puzzle>=_td.puzzles.size()-1
if last:
dlg.show_dialog("*",TIERS[tier].name+" Complete!","All constellations in this chapter stabilized.",[{"text":"Next Chapter","action":_on_next_tier}])
else:
dlg.show_dialog("*","Constellation Stable","Puzzle %d solved."%(puzzle+1),[{"text":"Next Puzzle","action":_on_next_puzzle},{"text":"Menu","color":Color(1,1,1,0.5),"action":enter_splash}])
func _tier_complete()->void:
dlg.show_dialog("*",TIERS[tier].name+" Complete!","All puzzles in this tier complete.",[{"text":"Next Chapter","action":_on_next_tier}])
func _game_complete()->void:
dlg.show_dialog("*","The Cosmos is Yours","You mastered every constellation.",[{"text":"Title Screen","action":enter_splash}])
func _on_next_puzzle()->void: dlg.visible=false; _load(puzzle+1)
func _on_next_tier()->void:
dlg.visible=false; tier+=1
if tier>=TIERS.size(): _game_complete()
else: _gen(); show_tier_intro()
func _on_menu()->void: dlg.visible=false; pb.clear_all(); pb.visible=false; hud.visible=false; enter_splash()
func _on_undo()->void: pb.undo()
func _on_reset()->void: pb.reset_puzzle()
func _on_hint()->void: pb.use_hint()
func _show_toast(msg:String)->void:
toast.text=msg
if _tw and _tw.is_valid(): _tw.kill()
_tw=create_tween(); _tw.tween_property(toast,"modulate",Color(1,1,1,0.85),0.2)
_tw.tween_interval(1.5); _tw.tween_property(toast,"modulate",Color(1,1,1,0),0.3)
| # | File | Type |
|---|---|---|
| 1-4 | Autoload/*.gd | Script-only singletons (register in Project Settings) |
| 5-6 | Scenes/Planet.tscn + Scripts/Planet.gd | Area2D with CollisionShape2D + Labels |
| 7-8 | Scenes/Starlane.tscn + Scripts/Starlane.gd | Line2D with Label children |
| 9-10 | Scenes/PuzzleBoard.tscn + Scripts/PuzzleBoard.gd | Node2D — creates Planet/Starlane instances |
| 11-12 | Scenes/HUD.tscn + Scripts/HUD.gd | CanvasLayer overlay |
| 13-14 | Scenes/MenuScreen.tscn + Scripts/MenuScreen.gd | CanvasLayer splash |
| 15-16 | Scenes/TierIntro.tscn + Scripts/TierIntro.gd | CanvasLayer intro card |
| 17-18 | Scenes/DialogOverlay.tscn + Scripts/DialogOverlay.gd | CanvasLayer dialog |
| 19-20 | Scenes/Main.tscn + Scripts/Main.gd | Root — instances all scenes |