feat: wire _ph placeholder sprites into actors (mech, grunt, burster, slug, scrap)

Operator asked for placeholder sprites for playtest feel. Actors now
render the hand-authored pixel maps (tools/aseprite/maps -> assets/
sprites_ph, committed) via code-created Sprite2D — no scene texture
UIDs to hand-author. Dynamic feedback preserved: grunt/burster heat
tint + hit flash drive sprite.modulate, heat-aura ring stays a _draw
overlay; salvage fade drives modulate.a. Arena floor/walls, HUD, and
FX stay primitives. CLAUDE.md greybox-art note updated (was 'no
sprites'); real art (watercolor scenery + pixel sprites) still later.

Smoke gate 3/3 green on nh3-dev headless 4.7.1.
This commit is contained in:
2026-08-14 22:39:03 -07:00
parent 21cca5cbdb
commit 5ba185f775
17 changed files with 256 additions and 31 deletions
-3
View File
@@ -4,6 +4,3 @@
# OS noise # OS noise
.DS_Store .DS_Store
# Generated placeholder sprites (regenerate via tools/aseprite/)
assets/sprites_ph/
+9 -3
View File
@@ -116,7 +116,12 @@ Non-negotiable canon highlights (full rationale in CONCEPT.md):
yet). yet).
- 2D, pixel-first: integer scaling, nearest-neighbor filtering - 2D, pixel-first: integer scaling, nearest-neighbor filtering
(set in project.godot). Hi-bit mixed-res effects layer comes (set in project.godot). Hi-bit mixed-res effects layer comes
later — greybox is deliberately art-free. later. Actors now use `_ph` placeholder sprites (hand-authored
pixel maps in `tools/aseprite/maps/`, rendered to
`assets/sprites_ph/`); arena floor/walls, HUD, and FX stay
primitives. Real art (watercolor scenery + pixel sprites, CONCEPT.md
§ Tech & art direction) comes at the art pass — placeholders only,
no polish, until then.
## Current phase: GREYBOX (v1 per ROADMAP.md) ## Current phase: GREYBOX (v1 per ROADMAP.md)
@@ -126,5 +131,6 @@ KINETIC) · the KINETIC+HEAT chemistry row (knock a heated enemy
into others → heat spreads on impact) · two enemy types (untagged into others → heat spreads on impact) · two enemy types (untagged
grunt swarm + self-heating burster). It exists to falsify three grunt swarm + self-heating burster). It exists to falsify three
bets: positioning ↔ build interaction, card-freeze rhythm, bets: positioning ↔ build interaction, card-freeze rhythm,
chemistry delight. Greybox art: primitives and placeholder shapes chemistry delight. Greybox art: `_ph` placeholder pixel sprites for
only — no sprites, no polish. actors, primitives for arena/HUD/FX — no polish; the real art pass
comes later.
Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dwcirgi6ts8it"
path="res://.godot/imported/burster_ph.png-16f74ec80319b3fa303d5ad5aab3a1a4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites_ph/burster_ph.png"
dest_files=["res://.godot/imported/burster_ph.png-16f74ec80319b3fa303d5ad5aab3a1a4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cabtcqh1edeqb"
path="res://.godot/imported/grunt_ph.png-d3ab8092460f76ac642a3b5e3ce6cc97.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites_ph/grunt_ph.png"
dest_files=["res://.godot/imported/grunt_ph.png-d3ab8092460f76ac642a3b5e3ce6cc97.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ji7t7i6inf0q"
path="res://.godot/imported/m03_ph.png-a19785a989146d95e9780ea78de3cb0a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites_ph/m03_ph.png"
dest_files=["res://.godot/imported/m03_ph.png-a19785a989146d95e9780ea78de3cb0a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://76bnxqxl7uxt"
path="res://.godot/imported/scrap_ph.png-780db8d2b3ed50cbb4f3dc204a549011.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites_ph/scrap_ph.png"
dest_files=["res://.godot/imported/scrap_ph.png-780db8d2b3ed50cbb4f3dc204a549011.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bnbsde6j7gwsi"
path="res://.godot/imported/slug_ph.png-c68013faba14e2a7a9a40558a57d8215.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites_ph/slug_ph.png"
dest_files=["res://.godot/imported/slug_ph.png-c68013faba14e2a7a9a40558a57d8215.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+1
View File
@@ -15,6 +15,7 @@ contact_damage = 3.0
body_radius = 11.0 body_radius = 11.0
base_color = Color(0.6, 0.4, 0.25, 1) base_color = Color(0.6, 0.4, 0.25, 1)
salvage_value = 3 salvage_value = 3
sprite_path = "res://assets/sprites_ph/burster_ph.png"
self_heat_rate = 0.5 self_heat_rate = 0.5
detonation_damage = 2.5 detonation_damage = 2.5
detonation_mech_damage = 15.0 detonation_mech_damage = 15.0
+26 -7
View File
@@ -28,6 +28,8 @@ signal died
@export var body_radius := 8.0 @export var body_radius := 8.0
@export var base_color := Color(0.45, 0.47, 0.4) @export var base_color := Color(0.45, 0.47, 0.4)
@export var salvage_value := 1 @export var salvage_value := 1
## Placeholder sprite; overridden per scene (grunt vs burster).
@export var sprite_path := "res://assets/sprites_ph/grunt_ph.png"
## > 0 makes this a burster: heat/second it applies to itself. ## > 0 makes this a burster: heat/second it applies to itself.
@export var self_heat_rate := 0.0 @export var self_heat_rate := 0.0
@@ -54,6 +56,7 @@ var _chem_cd := 0.0
var _flash := 0.0 var _flash := 0.0
var _dead := false var _dead := false
var _mech: Node2D var _mech: Node2D
var _sprite: Sprite2D
func _ready() -> void: func _ready() -> void:
@@ -61,6 +64,10 @@ func _ready() -> void:
if self_heat_rate > 0.0: if self_heat_rate > 0.0:
add_to_group("bursters") add_to_group("bursters")
_mech = get_tree().get_first_node_in_group("mech") _mech = get_tree().get_first_node_in_group("mech")
_sprite = Sprite2D.new()
_sprite.texture = load(sprite_path)
add_child(_sprite)
_update_tint()
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
@@ -100,6 +107,20 @@ func _physics_process(delta: float) -> void:
if _flash > 0.0: if _flash > 0.0:
_flash -= delta _flash -= delta
queue_redraw() queue_redraw()
_update_tint()
## The sprite carries the dynamic feedback the primitive used to: heat
## tints the body toward orange (HEAT's read), a hit flashes it bright.
## The heat-aura ring stays a _draw overlay (below the sprite).
func _update_tint() -> void:
if _sprite == null:
return
if _flash > 0.0:
_sprite.modulate = Color(2.5, 2.5, 2.5)
return
var heat_k := clampf(heat / heat_threshold, 0.0, 1.0)
_sprite.modulate = Color(1, 1, 1).lerp(Color(1.5, 0.75, 0.4), heat_k)
func hit(damage: float, kb: Vector2) -> void: func hit(damage: float, kb: Vector2) -> void:
@@ -108,6 +129,7 @@ func hit(damage: float, kb: Vector2) -> void:
hp -= damage hp -= damage
_knockback += kb _knockback += kb
_flash = 0.12 _flash = 0.12
_update_tint()
queue_redraw() queue_redraw()
if hp <= 0.0: if hp <= 0.0:
_die() _die()
@@ -117,6 +139,7 @@ func add_heat(amount: float) -> void:
if _dead: if _dead:
return return
heat += amount heat += amount
_update_tint()
queue_redraw() queue_redraw()
@@ -146,12 +169,8 @@ func _die() -> void:
func _draw() -> void: func _draw() -> void:
var body := base_color # Body is now the sprite; _draw carries only the heat-aura ring
# (renders under the sprite, extends past it — a visible glow).
var heat_k := clampf(heat / heat_threshold, 0.0, 1.0) var heat_k := clampf(heat / heat_threshold, 0.0, 1.0)
if heat_k > 0.0: if heat_k > 0.0:
body = body.lerp(Color(0.95, 0.5, 0.15), heat_k * 0.8) draw_arc(Vector2.ZERO, body_radius + 3.0, 0.0, TAU, 24, Color(0.95, 0.55, 0.2, 0.4 + heat_k * 0.5), 1.5)
if _flash > 0.0:
body = Color(0.95, 0.95, 0.95)
draw_circle(Vector2.ZERO, body_radius, body)
if heat_k > 0.0:
draw_arc(Vector2.ZERO, body_radius + 2.0, 0.0, TAU, 24, Color(0.95, 0.55, 0.2, 0.4 + heat_k * 0.5), 1.5)
+5 -5
View File
@@ -9,12 +9,17 @@ extends Area2D
@export var knockback := 220.0 @export var knockback := 220.0
@export var lifetime := 0.8 @export var lifetime := 0.8
const SPRITE := preload("res://assets/sprites_ph/slug_ph.png")
var direction := Vector2.RIGHT var direction := Vector2.RIGHT
func _ready() -> void: func _ready() -> void:
add_to_group("projectiles") add_to_group("projectiles")
rotation = direction.angle() rotation = direction.angle()
var spr := Sprite2D.new()
spr.texture = SPRITE
add_child(spr)
body_entered.connect(_on_body_entered) body_entered.connect(_on_body_entered)
@@ -29,8 +34,3 @@ func _on_body_entered(body: Node) -> void:
if body.is_in_group("enemies"): if body.is_in_group("enemies"):
body.hit(damage, direction * knockback) body.hit(damage, direction * knockback)
queue_free() queue_free()
func _draw() -> void:
# KINETIC hue: pale steel-blue (placeholder — tag colors unassigned in canon).
draw_rect(Rect2(-5, -1.5, 10, 3), Color(0.75, 0.85, 0.95))
+6 -5
View File
@@ -16,9 +16,15 @@ var hull: float
var _dash := Vector2.ZERO var _dash := Vector2.ZERO
const SPRITE := preload("res://assets/sprites_ph/m03_ph.png")
func _ready() -> void: func _ready() -> void:
add_to_group("mech") add_to_group("mech")
hull = hull_max hull = hull_max
var spr := Sprite2D.new()
spr.texture = SPRITE
add_child(spr)
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
@@ -42,8 +48,3 @@ func take_damage(amount: float) -> void:
if hull <= 0.0: if hull <= 0.0:
# The run manager decides what death means (tally + relaunch). # The run manager decides what death means (tally + relaunch).
destroyed.emit() destroyed.emit()
func _draw() -> void:
draw_rect(Rect2(-10, -12, 20, 24), Color(0.82, 0.84, 0.88))
draw_rect(Rect2(-6, -6, 12, 12), Color(0.55, 0.6, 0.68))
+9 -8
View File
@@ -4,12 +4,15 @@ extends Area2D
## levels). Drifts to the mech inside the magnet radius, collected on ## levels). Drifts to the mech inside the magnet radius, collected on
## contact, fades out if ignored. Constructed in code, no scene. ## contact, fades out if ignored. Constructed in code, no scene.
const SPRITE := preload("res://assets/sprites_ph/scrap_ph.png")
var value := 1 var value := 1
var magnet_radius := 70.0 var magnet_radius := 70.0
var drift_speed := 150.0 var drift_speed := 150.0
var lifetime := 20.0 var lifetime := 20.0
var _t := 0.0 var _t := 0.0
var _sprite: Sprite2D
func _ready() -> void: func _ready() -> void:
@@ -22,6 +25,9 @@ func _ready() -> void:
sh.radius = 6.0 sh.radius = 6.0
cs.shape = sh cs.shape = sh
add_child(cs) add_child(cs)
_sprite = Sprite2D.new()
_sprite.texture = SPRITE
add_child(_sprite)
body_entered.connect(_on_body_entered) body_entered.connect(_on_body_entered)
@@ -33,8 +39,9 @@ func _physics_process(delta: float) -> void:
var mech := get_tree().get_first_node_in_group("mech") as Node2D var mech := get_tree().get_first_node_in_group("mech") as Node2D
if mech != null and global_position.distance_to(mech.global_position) <= magnet_radius: if mech != null and global_position.distance_to(mech.global_position) <= magnet_radius:
global_position = global_position.move_toward(mech.global_position, drift_speed * delta) global_position = global_position.move_toward(mech.global_position, drift_speed * delta)
if _t >= lifetime - 3.0: # Fade the sprite out over the last 3s of life.
queue_redraw() if _t >= lifetime - 3.0 and _sprite != null:
_sprite.modulate.a = clampf((lifetime - _t) / 3.0, 0.0, 1.0)
func _on_body_entered(body: Node) -> void: func _on_body_entered(body: Node) -> void:
@@ -43,9 +50,3 @@ func _on_body_entered(body: Node) -> void:
if hand != null: if hand != null:
hand.add_salvage(value) hand.add_salvage(value)
queue_free() queue_free()
func _draw() -> void:
var a := 1.0 if _t < lifetime - 3.0 else clampf((lifetime - _t) / 3.0, 0.0, 1.0)
var pts := PackedVector2Array([Vector2(0, -5), Vector2(4, 0), Vector2(0, 5), Vector2(-4, 0)])
draw_colored_polygon(pts, Color(0.95, 0.8, 0.35, a))