From deba758e875b0d8d4e6c35a6209694622aef2d8c Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Fri, 2 Oct 2026 13:29:32 +0200 Subject: [PATCH] =?UTF-8?q?Pixelgrafik=20f=C3=BCr=20die=20Dinge=20der=20Vi?= =?UTF-8?q?trine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toter Boden hat Risse, lebendiger Boden Gras, Blumen und einen unregelmäßigen Rand. Moos funkelt, die Ranke trägt Blätter, die Faserpflanze wächst in sechs Stufen bis zu den Fasern und wiegt sich, das Weidetier zieht im Sprung die Beine an, und die Ruine leuchtet, wenn sie erwacht. Co-Authored-By: Claude Haiku 4.5 --- game/scripts/art/pixel_style.gd | 327 ++++++++++++++++++++++++++++ game/tests/test_pixel_things.gd | 59 +++++ game/tests/test_pixel_things.gd.uid | 1 + 3 files changed, 387 insertions(+) create mode 100644 game/tests/test_pixel_things.gd create mode 100644 game/tests/test_pixel_things.gd.uid diff --git a/game/scripts/art/pixel_style.gd b/game/scripts/art/pixel_style.gd index 4c011d1..a89d643 100644 --- a/game/scripts/art/pixel_style.gd +++ b/game/scripts/art/pixel_style.gd @@ -14,6 +14,8 @@ const TILE := 16 const FACE := 6 ## Images of a burst of chips, from the hit to its end. const HIT_FRAMES := 6 +## Stages of a growing plant, from the seed to the fibres. +const GROWTH_STAGES := 6 ## Size of a figure: a tile wide and one and a half tiles tall. const FIGURE_SIZE := Vector2i(16, 24) const CLEAR := Color(0, 0, 0, 0) @@ -25,6 +27,9 @@ var players: Array[Color] = [] ## True for outlines in a darker shade of what they surround, false for one ## dark outline colour. var tinted_outlines := false +## How strongly living things glow, from 0 (not at all) to 1; the subclass +## chooses it. +var glow := 0.0 var _textures: Dictionary[String, Texture2D] = {} @@ -471,3 +476,325 @@ func _figure_image(colour: int, frame: int, left: bool) -> Pixels: if left: pixels.image.flip_x() return pixels + + +# Things of later milestones, shown in the showcase. + + +func _dead_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: + var shadow: int = state.get("solid", 0) & (TileShape.N | TileShape.W | TileShape.NW) + var variant: int = state.get("variant", 0) % 8 + var key := "dead/%d/%d" % [shadow, variant] + _put(canvas, _image(key, func() -> Pixels: return _crack_soil(_earth(variant, shadow), variant)), rect.position) + + +## Dry cracks in every other tile of dead soil. Returns `pixels`. +func _crack_soil(pixels: Pixels, variant: int) -> Pixels: + if variant % 2 == 0: + var at := _spot(12, variant, 3, 7) + for step in 5: + pixels.put(at.x, at.y, _c("dead_crack")) + if step == 2: + pixels.put(at.x, at.y + 1, _c("dead_crack")) + pixels.put(at.x - 1, at.y + 2, _c("dead_crack")) + at += Vector2i(1, -1 if _noise(step, 5, variant) < 0.4 else 0) + return pixels + + +func _living_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: + var living := _shape(state.get("living", 0)) + var variant: int = state.get("variant", 0) % 6 + _ground(canvas, rect, {"variant": state.get("variant", 0)}) + _put(canvas, _image("living/%d/%d" % [living, variant], func() -> Pixels: return _grass_image(living, variant)), rect.position) + + +## Grass with blades and a few flowers, with a soft edge toward the sides +## that are not living. +func _grass_image(living: int, variant: int) -> Pixels: + var open_n := (living & TileShape.N) == 0 + var open_e := (living & TileShape.E) == 0 + var open_s := (living & TileShape.S) == 0 + var open_w := (living & TileShape.W) == 0 + var pixels := Pixels.new(TILE, TILE) + pixels.rect(0, 0, TILE, TILE, _c("grass")) + for tuft in 6: + var at := _spot(tuft + 10, variant, 2, 12) + pixels.put(at.x, at.y, _c("grass_dark")) + pixels.put(at.x - 1, at.y - 1, _c("grass_dark")) + pixels.put(at.x + 1, at.y - 1, _c("grass_dark")) + for blade in 4: + var at := _spot(blade + 20, variant, 1, 14) + pixels.put(at.x, at.y, _c("grass_light")) + if variant % 3 == 0: + var x := 4 + variant % 7 + var y := 5 + variant % 5 + var petal := _c("flower_b") if variant % 2 == 0 else _c("flower_c") + for side in Pixels.SIDES: + pixels.put(x + side.x, y + side.y, petal) + pixels.put(x, y, _c("flower_a")) + if glow > 0.0: + for spark in 2: + var light := _c("spore") + light.a = glow * 0.8 + pixels.put(2 + int(_noise(spark, 21, variant) * 12.0), 2 + int(_noise(spark, 22, variant) * 12.0), light) + # A ragged edge toward bare ground. + for i in TILE: + var ragged := 1 if _noise(i, 23, variant) < 0.45 else 0 + if open_n: + _edge(pixels, Vector2i(i, 0), Vector2i(0, 1), ragged) + if open_s: + _edge(pixels, Vector2i(i, TILE - 1), Vector2i(0, -1), ragged) + if open_w: + _edge(pixels, Vector2i(0, i), Vector2i(1, 0), ragged) + if open_e: + _edge(pixels, Vector2i(TILE - 1, i), Vector2i(-1, 0), ragged) + if open_n and open_w: + _round(pixels, Vector2i(0, 0), Vector2i(1, 1), _c("grass_edge")) + if open_n and open_e: + _round(pixels, Vector2i(TILE - 1, 0), Vector2i(-1, 1), _c("grass_edge")) + if open_s and open_w: + _round(pixels, Vector2i(0, TILE - 1), Vector2i(1, -1), _c("grass_edge")) + if open_s and open_e: + _round(pixels, Vector2i(TILE - 1, TILE - 1), Vector2i(-1, -1), _c("grass_edge")) + return pixels + + +## Clears `cut` pixels at `at` and draws the darker edge of the grass behind +## them, one step further in the direction `inward`. +func _edge(pixels: Pixels, at: Vector2i, inward: Vector2i, cut: int) -> void: + for depth in cut: + var gone := at + inward * depth + pixels.put(gone.x, gone.y, CLEAR) + var edge := at + inward * cut + pixels.put(edge.x, edge.y, _c("grass_edge")) + + +func _moss(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: + var variant: int = state.get("variant", 0) % 3 + var twinkle := int(float(state.get("time", 0.0)) * 2.5) % 4 + var foot := Vector2(rect.get_center().x, rect.end.y) + _put(canvas, _image("moss/%d/%d" % [variant, twinkle], func() -> Pixels: return _moss_image(variant, twinkle)), foot - Vector2(8, 13)) + + +## A cushion of moss whose spores twinkle one after another. +func _moss_image(variant: int, twinkle: int) -> Pixels: + var pixels := Pixels.new(TILE, 13) + var shift := variant - 1 + for bump: Vector4 in [Vector4(4.5 + shift, 9.5, 4, 3.2), Vector4(11, 9.2, 4.3, 3.5), Vector4(7.8 - shift, 6.8, 3.8, 3.4)]: + pixels.ellipse(bump.x, bump.y, bump.z, bump.w, _c("moss")) + for y in pixels.size.y: + for x in pixels.size.x: + if not pixels.filled(x, y): + continue + if y >= 11: + pixels.put(x, y, _c("moss_dark")) + elif not pixels.filled(x, y - 1) or not pixels.filled(x, y - 2): + pixels.put(x, y, _c("moss_light")) + pixels.outline(_ink(_c("moss")), tinted_outlines) + var spores: Array[Vector2i] = [Vector2i(4 + shift, 8), Vector2i(8 - shift, 5), Vector2i(12, 8)] + for index in spores.size(): + var at := spores[index] + var bright := index == twinkle + var spore := _c("spore") if bright else _c("spore").darkened(0.2) + pixels.put(at.x, at.y, spore) + pixels.put(at.x + 1, at.y, spore) + if bright: + pixels.put(at.x, at.y - 1, _c("spore")) + if glow > 0.0: + var halo := _c("spore") + halo.a = 0.45 * glow + for side in Pixels.SIDES: + pixels.shade(at.x + side.x, at.y + side.y + 1, halo) + return pixels + + +func _vine(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: + var points: PackedVector2Array = state.get("points", PackedVector2Array()) + if points.size() < 2: + return + var origin := (rect.position - Vector2(6, 6)).floor() + var size := Vector2i(ceili(rect.size.x) + 13, ceili(rect.size.y) + 13) + var key := "vine/%d/%d/%d" % [hash(points), size.x, size.y] + _put(canvas, _image(key, func() -> Pixels: return _vine_image(points, origin, size)), origin) + + +## A vine two pixels thick along `points`, with leaves on alternate sides. +func _vine_image(points: PackedVector2Array, origin: Vector2, size: Vector2i) -> Pixels: + var pixels := Pixels.new(size.x, size.y) + for i in points.size() - 1: + var from := Vector2i((points[i] - origin).round()) + var to := Vector2i((points[i + 1] - origin).round()) + pixels.line(from.x, from.y - 1, to.x, to.y - 1, _c("leaf")) + pixels.line(from.x, from.y, to.x, to.y, _c("vine")) + pixels.line(from.x, from.y + 1, to.x, to.y + 1, _c("vine_dark")) + var length := 0.0 + for i in points.size() - 1: + length += points[i].distance_to(points[i + 1]) + var leaves := int(length / 9.0) + for leaf in leaves: + var at := Vector2i((along(points, (leaf + 0.5) / leaves) - origin).round()) + var up := leaf % 2 == 0 + var dy := -2 if up else 2 + var tip := -1 if up else 1 + pixels.put(at.x, at.y + dy, _c("leaf")) + pixels.put(at.x + 1, at.y + dy, _c("leaf")) + pixels.put(at.x + 1, at.y + dy + tip, _c("leaf")) + pixels.put(at.x + 2, at.y + dy + tip, _c("leaf_dark")) + pixels.outline(_ink(_c("vine")), tinted_outlines) + return pixels + + +func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: + var stage := clampi(int(float(state.get("growth", 1.0)) * GROWTH_STAGES), 0, GROWTH_STAGES - 1) + var lean := clampi(roundi(sway(state.get("time", 0.0), 3) * 1.4), -1, 1) + var foot := Vector2(rect.get_center().x, rect.end.y) + _put(canvas, _image("plant/%d/%d" % [stage, lean], func() -> Pixels: return _plant_image(stage, lean)), foot - Vector2(8, 28)) + + +## A fibre plant at growth stage `stage`, its top leaning by `lean` pixels: +## a seed in a small mound, a sprout, a stalk with leaves, buds and at last +## white tufts of fibre. +func _plant_image(stage: int, lean: int) -> Pixels: + var pixels := Pixels.new(TILE, 28) + pixels.rect(5, 26, 6, 1, _c("ground_dark")) + pixels.rect(4, 27, 8, 1, _c("ground_dark")) + if stage == 0: + pixels.put(7, 25, _c("seed")) + pixels.put(8, 25, _c("seed")) + pixels.outline(_ink(_c("seed")), tinted_outlines) + return pixels + var heights: Array[int] = [0, 4, 8, 13, 17, 19] + var height := heights[stage] + var top := Vector2i(8, 25) + for i in height: + var x := 8 + roundi(lean * 2.0 * i / 19.0) + top = Vector2i(x, 25 - i) + pixels.put(top.x, top.y, _c("stem")) + @warning_ignore("integer_division") + for pair in range(1, height / 3 + 1): + var y := 26 - pair * 3 + var x := 8 + roundi(lean * 2.0 * (25 - y) / 19.0) + var side := 1 if pair % 2 == 0 else -1 + pixels.put(x + side, y, _c("leaf")) + pixels.put(x + side * 2, y, _c("leaf")) + pixels.put(x + side * 2, y - 1, _c("leaf")) + pixels.put(x + side * 3, y - 1, _c("leaf_dark")) + pixels.put(x - side, y - 1, _c("leaf_dark")) + if stage == 1: + pixels.put(top.x - 1, top.y, _c("leaf")) + pixels.put(top.x + 1, top.y, _c("leaf")) + if stage == 4: + pixels.ellipse(top.x + 0.5, top.y - 0.5, 1.6, 1.6, _c("bud")) + if stage == 5: + for tuft: Vector3 in [Vector3(0, -2, 2.3), Vector3(-3, 1, 1.8), Vector3(3, 2, 1.8)]: + pixels.ellipse(top.x + 0.5 + tuft.x, top.y + tuft.y, tuft.z, tuft.z, _c("fibre")) + pixels.put(top.x + int(tuft.x), top.y + int(tuft.y) + 1, _c("fibre_shade")) + pixels.outline(_ink(_c("stem")), tinted_outlines) + return pixels + + +func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void: + _put(canvas, _image("stone", func() -> Pixels: return _stone_image()), Vector2(rect.get_center().x - 4, rect.end.y - 8)) + + +func _stone_image() -> Pixels: + var pixels := Pixels.new(8, 8) + pixels.ellipse(4, 4.2, 3.2, 2.7, _c("stone")) + pixels.rect(2, 6, 4, 1, _c("stone_dark")) + pixels.put(2, 3, _c("stone_light")) + pixels.put(3, 2, _c("stone_light")) + pixels.outline(_ink(_c("stone")), tinted_outlines) + return pixels + + +func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: + var height := roundi(sin(PI * clampf(state.get("hop", 0.0), 0.0, 1.0)) * 5.0) + var left: bool = float(state.get("facing", 1.0)) < 0.0 + var foot := Vector2(rect.get_center().x, rect.end.y) + var width := 14 - height + _put(canvas, _image("shadow/%d" % width, func() -> Pixels: return _shadow_image(width)), foot - Vector2(width / 2.0, 2)) + var tucked := height > 1 + _put(canvas, _image("grazer/%s/%s" % [tucked, left], func() -> Pixels: return _grazer_image(tucked, left)), foot - Vector2(10, 16 + height)) + + +## A woolly animal, facing right or, with `left`, left; in the air its legs +## are `tucked`. +func _grazer_image(tucked: bool, left: bool) -> Pixels: + var pixels := Pixels.new(20, 16) + for leg in [5, 7, 11, 13]: + pixels.rect(leg, 12, 1, 1 if tucked else 3, _c("hoof")) + for puff: Vector4 in [Vector4(9, 8, 6.5, 4.5), Vector4(5.5, 9.5, 4, 3.5), Vector4(12.5, 9.5, 4.5, 3.5), Vector4(8, 5.8, 4, 3), Vector4(11.5, 6.3, 3.5, 3)]: + pixels.ellipse(puff.x, puff.y, puff.z, puff.w, _c("wool")) + for y in pixels.size.y: + for x in pixels.size.x: + if pixels.at(x, y) == _c("wool"): + if y >= 11: + pixels.put(x, y, _c("wool_shade")) + elif not pixels.filled(x, y - 1) and _noise(x, y, 4) < 0.6: + pixels.put(x, y, _c("wool_light")) + pixels.ellipse(16.2, 8.0, 2.6, 3.2, _c("face")) + pixels.put(14, 5, _c("face")) + pixels.put(15, 5, _c("face")) + pixels.put(17, 7, _c("wool_light")) + pixels.outline(_ink(_c("wool")), tinted_outlines) + if left: + pixels.image.flip_x() + return pixels + + +func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: + var awake := clampf(state.get("awake", 0.0), 0.0, 1.0) + _put(canvas, _image("ruin", func() -> Pixels: return _ruin_image()), rect.position) + _put(canvas, _image("ruin_glow", func() -> Pixels: return _ruin_glow()), rect.position, Color(1, 1, 1, awake)) + + +## A gate of old stone blocks, three tiles wide, with moss on top and a dark +## doorway. +func _ruin_image() -> Pixels: + var pixels := Pixels.new(TILE * 3, TILE * 3) + var stone := _c("ruin") + pixels.rect(3, 12, 12, 34, stone) + pixels.rect(33, 12, 12, 34, stone) + pixels.rect(1, 6, 46, 9, stone) + pixels.rect(1, 3, 46, 3, _c("ruin_light")) + pixels.rect(15, 15, 18, 31, _c("door")) + pixels.rect(9, 44, 30, 2, _c("ruin_dark")) + pixels.rect(7, 46, 34, 2, stone) + # Blocks: a joint every five rows, and between the blocks of each row. + for course in 8: + var y := 8 + course * 5 + for x in range(1, 47): + if pixels.at(x, y) == stone: + pixels.put(x, y, _c("ruin_dark")) + var offset := 0 if course % 2 == 0 else 4 + for x in range(3 + offset, 47, 8): + for row in range(y + 1, y + 5): + if pixels.at(x, row) == stone: + pixels.put(x, row, _c("ruin_dark")) + for x in range(1, 47): + if _noise(x, 40, 1) < 0.45: + pixels.put(x, 3, _c("ruin_moss")) + if _noise(x, 41, 1) < 0.25: + pixels.put(x, 4, _c("ruin_moss")) + for strand: Vector2i in [Vector2i(5, 4), Vector2i(12, 3), Vector2i(36, 5), Vector2i(43, 2)]: + pixels.rect(strand.x, 15, 1, strand.y, _c("ruin_moss")) + pixels.outline(_ink(stone), tinted_outlines) + return pixels + + +## The light in the doorway and the runes above it, shown as the ruin wakes. +func _ruin_glow() -> Pixels: + var pixels := Pixels.new(TILE * 3, TILE * 3) + var center := Vector2(24, 31) + for y in range(15, 46): + for x in range(15, 33): + var distance := Vector2(x + 0.5, y + 0.5).distance_to(center) + var light := _c("glow_light") if distance < 4.0 else _c("glow") + light.a = clampf(1.0 - distance / 13.0, 0.0, 1.0) * (0.8 + 0.2 * glow) + if light.a > 0.05: + pixels.put(x, y, light) + for rune in 3: + var x := 16 + rune * 7 + for dot: Vector2i in [Vector2i(0, 0), Vector2i(1, 0), Vector2i(1, 1), Vector2i(0, 2), Vector2i(1, 2)]: + pixels.put(x + dot.x + (rune % 2), 8 + dot.y, _c("glow")) + return pixels diff --git a/game/tests/test_pixel_things.gd b/game/tests/test_pixel_things.gd new file mode 100644 index 0000000..4574667 --- /dev/null +++ b/game/tests/test_pixel_things.gd @@ -0,0 +1,59 @@ +extends TestCase +## The pixel art of the things of later milestones in the showcase. + + +## The highest row of `image` with a pixel that is not transparent. +static func _top(image: Image) -> int: + for y in image.get_height(): + for x in image.get_width(): + if image.get_pixel(x, y).a > 0.0: + return y + return image.get_height() + + +func test_a_plant_grows_taller_from_stage_to_stage() -> void: + var style := WarmStyle.new() + var tops: Array[int] = [] + for stage in PixelStyle.GROWTH_STAGES: + tops.append(_top(style._plant_image(stage, 0).image)) + for stage in range(1, tops.size()): + check(tops[stage] < tops[stage - 1], "stage %d is taller than stage %d: %s" % [stage, stage - 1, tops]) + + +func test_grass_has_a_ragged_edge_only_toward_bare_ground() -> void: + var style := WarmStyle.new() + var inside := style._grass_image(255, 0).image + var lone := style._grass_image(0, 0).image + var tile := PixelStyle.TILE + var inside_gaps := 0 + var lone_gaps := 0 + for y in tile: + for x in tile: + inside_gaps += 1 if inside.get_pixel(x, y).a == 0.0 else 0 + lone_gaps += 1 if lone.get_pixel(x, y).a == 0.0 else 0 + check_eq(inside_gaps, 0, "living all around: no gaps") + check(lone_gaps > 4, "bare ground all around: a ragged, round edge (%d gaps)" % lone_gaps) + check_eq(lone.get_pixel(0, 0).a, 0.0, "round at the corner") + + +func test_dead_soil_is_the_bare_earth_with_cracks() -> void: + var style := WarmStyle.new() + var earth := style._earth(0, 0).image + var cracked := style._crack_soil(style._earth(0, 0), 0).image + check(cracked.get_data() != earth.get_data(), "cracks in every other tile") + check_eq(style._crack_soil(style._earth(1, 0), 1).image.get_data(), style._earth(1, 0).image.get_data(), "none in the others") + + +func test_an_animal_in_the_air_tucks_its_legs() -> void: + var style := WarmStyle.new() + var standing := style._grazer_image(false, false).image + var tucked := style._grazer_image(true, false).image + check(standing.get_pixel(5, 14).a > 0.0, "standing on its legs") + check_eq(tucked.get_pixel(5, 14).a, 0.0, "legs tucked in the air") + + +func test_the_ruin_glows_in_its_doorway() -> void: + var style := WarmStyle.new() + var light := style._ruin_glow().image + check(light.get_pixel(24, 31).a > 0.5, "bright in the middle of the doorway") + check_eq(light.get_pixel(3, 30).a, 0.0, "dark on the walls") diff --git a/game/tests/test_pixel_things.gd.uid b/game/tests/test_pixel_things.gd.uid new file mode 100644 index 0000000..88eb125 --- /dev/null +++ b/game/tests/test_pixel_things.gd.uid @@ -0,0 +1 @@ +uid://cbm4wa0gxi4p1