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