diff --git a/game/scripts/avatar.gd b/game/scripts/avatar.gd index cde223c..f0ec1e3 100644 --- a/game/scripts/avatar.gd +++ b/game/scripts/avatar.gd @@ -1,7 +1,8 @@ class_name Avatar extends Node2D ## The local player's figure. It moves outside the lockstep simulation; only -## its pickaxe hits become commands. +## its pickaxe hits become commands. `art` draws it, stepping while it walks +## and facing the way it went last. ## Walking speed: five tiles per second. const SPEED := 5.0 * GridMotion.TILE_SIZE @@ -9,9 +10,30 @@ const RADIUS := 5.0 ## Longest frame that moves the figure in one go. Longer frames are cut, so ## that the figure cannot pass through a wall after a stall. const MAX_STEP_SECONDS := 0.1 +## How far the step of a walking figure turns per pixel, in radians: about +## two steps per second at walking speed. +const STEP_PER_PIXEL := 0.16 ## Callable(x: int, y: int) -> bool that says whether a tile can be walked on. var is_walkable: Callable +var art: Art +## The number of the player the figure belongs to, for its colour, or -1 +## before the player is in the world. +var player := -1 +## 1.0 while the figure faces right, -1.0 while it faces left. +var facing := 1.0 +## Grows while the figure walks; the styles swing the steps by it. +var step := 0.0 +## True while the figure walked in the last frame. +var moving := false + + +## Where a figure standing at `at` is drawn: a tile wide and one and a half +## tiles tall, with its feet just below its body, so that it stands upright +## in its tile. +static func figure_rect(at: Vector2) -> Rect2: + var size := GridMotion.TILE_SIZE + return Rect2(at.x - size / 2.0, at.y + RADIUS + 1.0 - size * 1.5, size, size * 1.5) func _process(delta: float) -> void: @@ -20,12 +42,23 @@ func _process(delta: float) -> void: ## Moves the figure in `direction` for `delta` seconds, sliding along walls. func walk(direction: Vector2, delta: float) -> void: + var was_moving := moving + moving = false if direction == Vector2.ZERO or not is_walkable.is_valid(): + if was_moving: + queue_redraw() return var motion := direction * SPEED * minf(delta, MAX_STEP_SECONDS) + var before := position position = GridMotion.move(position, motion, RADIUS, is_walkable) + var walked := position.distance_to(before) + moving = walked > 0.0 + step += walked * STEP_PER_PIXEL + if direction.x != 0.0: + facing = signf(direction.x) + queue_redraw() func _draw() -> void: - draw_circle(Vector2.ZERO, RADIUS + 1.0, Color(0.15, 0.1, 0.05)) - draw_circle(Vector2.ZERO, RADIUS, Color(0.95, 0.85, 0.6)) + if art != null: + art.draw(self, Art.FIGURE, figure_rect(Vector2.ZERO), {"player": player, "facing": facing, "step": step, "moving": moving}) diff --git a/game/scripts/figures.gd b/game/scripts/figures.gd index 0809836..5b8eea1 100644 --- a/game/scripts/figures.gd +++ b/game/scripts/figures.gd @@ -1,19 +1,27 @@ class_name Figures extends Node2D -## The figures of the other players, with their names. A new position comes -## over the network about ten times a second; in between, each figure glides -## toward the latest one. +## The figures of the other players, with their names, drawn by `art`. A new +## position comes over the network about ten times a second; in between, +## each figure glides toward the latest one, stepping and facing the way it +## goes. ## How quickly a figure closes the gap to its latest position, per second. const GLIDE := 15.0 -const COLOR := Color(0.55, 0.8, 0.95) -const OUTLINE := Color(0.05, 0.1, 0.15) +## Pixels per frame below which a gliding figure counts as standing. +const STANDING := 0.05 +## Size and width of the names, in pixels of the playing field. The names +## are drawn at the size they have on screen, so that they stay sharp. const NAME_SIZE := 8 const NAME_WIDTH := 80.0 +const NAME_OUTLINE := Color(0.05, 0.05, 0.06) var session: GwSession +var art: Art var _shown: Dictionary[int, Vector2] = {} var _latest: Dictionary[int, Vector2] = {} +var _facing: Dictionary[int, float] = {} +var _step: Dictionary[int, float] = {} +var _moving: Dictionary[int, bool] = {} ## Moves the figure of `player` toward `position`. A figure that was not @@ -27,14 +35,14 @@ func place(player: int, position: Vector2) -> void: ## Removes the figure of `player`. func forget(player: int) -> void: - _shown.erase(player) - _latest.erase(player) + for figures: Dictionary in [_shown, _latest, _facing, _step, _moving]: + figures.erase(player) queue_redraw() func clear() -> void: - _shown.clear() - _latest.clear() + for figures: Dictionary in [_shown, _latest, _facing, _step, _moving]: + figures.clear() queue_redraw() @@ -43,21 +51,42 @@ func shown_at(player: int) -> Vector2: return _shown.get(player, Vector2.INF) +## 1.0 if the figure of `player` faces right, -1.0 if it faces left. +func facing_of(player: int) -> float: + return _facing.get(player, 1.0) + + func _process(delta: float) -> void: if _shown.is_empty(): return var weight := minf(1.0, GLIDE * delta) for player in _shown: - _shown[player] = _shown[player].lerp(_latest[player], weight) + var before := _shown[player] + _shown[player] = before.lerp(_latest[player], weight) + var moved := _shown[player] - before + if absf(moved.x) > STANDING: + _facing[player] = signf(moved.x) + _step[player] = _step.get(player, 0.0) + moved.length() * Avatar.STEP_PER_PIXEL + _moving[player] = moved.length() > STANDING queue_redraw() func _draw() -> void: + if art == null: + return var font := ThemeDB.fallback_font + # The camera enlarges the field. Text drawn at its size on screen and then + # shrunk stays sharp; text drawn small and enlarged would blur. + var zoom := maxf(1.0, get_global_transform_with_canvas().get_scale().x) for player in _shown: var at := _shown[player] - draw_circle(at, Avatar.RADIUS + 1.0, OUTLINE) - draw_circle(at, Avatar.RADIUS, COLOR) + var state := {"player": player, "facing": facing_of(player), "step": _step.get(player, 0.0), "moving": _moving.get(player, false)} + art.draw(self, Art.FIGURE, Avatar.figure_rect(at), state) if session != null: - var label := Vector2(at.x - NAME_WIDTH / 2.0, at.y - Avatar.RADIUS - 3.0) - draw_string(font, label, session.player_name(player), HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH, NAME_SIZE) + var label := Vector2(at.x - NAME_WIDTH / 2.0, Avatar.figure_rect(at).position.y - 2.0) + var text := session.player_name(player) + var size := roundi(NAME_SIZE * zoom) + draw_set_transform(label, 0.0, Vector2.ONE / zoom) + draw_string_outline(font, Vector2.ZERO, text, HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH * zoom, size, maxi(2, roundi(zoom * 1.5)), NAME_OUTLINE) + draw_string(font, Vector2.ZERO, text, HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH * zoom, size) + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) diff --git a/game/scripts/main.gd b/game/scripts/main.gd index d8c8b85..2f64871 100644 --- a/game/scripts/main.gd +++ b/game/scripts/main.gd @@ -40,6 +40,8 @@ var session: GwSession ## The network connection of an open world or a guest, or null. var link: SessionLink var clock: TickClock +## The style the playing field is drawn in. +var art: Art var menu: Menu var map_view: MapView var marker: TileMarker @@ -75,13 +77,19 @@ func _ready() -> void: if _player_key.is_empty(): push_warning("the player key cannot be read: %s" % error_string(store.last_read_error)) clock = TickClock.new(GwSession.TICKS_PER_SECOND) + art = Art.new() + art.load_overrides(Art.OVERRIDE_DIR) map_view = MapView.new() + map_view.art = art add_child(map_view) marker = TileMarker.new() + marker.art = art add_child(marker) figures = Figures.new() + figures.art = art add_child(figures) avatar = Avatar.new() + avatar.art = art add_child(avatar) var camera := Camera2D.new() camera.zoom = CAMERA_ZOOM @@ -200,8 +208,9 @@ func _show_world() -> void: for node: Node2D in [avatar, marker, figures]: node.show() map_view.session = session - map_view.queue_redraw() + map_view.reload() figures.session = session + avatar.player = session.local_player() figures.clear() avatar.is_walkable = session.is_walkable avatar.position = GridMotion.tile_center(session.spawn_point()) @@ -230,7 +239,7 @@ func _leave(message: String) -> void: link = null session = null map_view.session = null - map_view.queue_redraw() + map_view.reload() avatar.is_walkable = Callable() figures.clear() _show_menu(message) @@ -295,7 +304,7 @@ func _on_notice(notice: Dictionary) -> void: if notice["resync"]: resyncs += 1 _keep_diverged_recording() - map_view.queue_redraw() + map_view.reload() hud.show_stone(session.item_count(GwSession.ITEM_STONE)) hud.flash("RESYNCED") else: @@ -390,8 +399,16 @@ func _apply(events: Array[Dictionary]) -> void: var local := session.local_player() for event in events: match event["type"]: - "tile_hit", "tile_changed": - map_view.queue_redraw() + "tile_hit": + var tile := Vector2i(event["x"], event["y"]) + map_view.refresh_tile(tile) + # The own hits showed their burst when they were made. + if event["player"] != local: + map_view.hit(tile) + "tile_changed": + var tile := Vector2i(event["x"], event["y"]) + map_view.refresh_tile(tile) + map_view.hit(tile) "item_gained": if event["player"] == local: hud.show_stone(session.item_count(GwSession.ITEM_STONE)) @@ -401,6 +418,9 @@ func _apply(events: Array[Dictionary]) -> void: "player_joined": if event["player"] != local: hud.flash("PLAYER_JOINED", [session.player_name(event["player"])]) + else: + avatar.player = local + avatar.queue_redraw() _update_status() "player_left": figures.forget(event["player"]) @@ -422,7 +442,9 @@ func _try_dig() -> void: dig_at(marker.tile) -## Queues a pickaxe hit on `tile`, at most once per cooldown. The caller must +## Queues a pickaxe hit on `tile`, at most once per cooldown, and shows its +## burst of chips at once: the hit itself lands with a later tick, which for a +## guest comes back from the host only after the round trip. The caller must ## check the reach: figures move outside the lockstep, so the core does not ## know where the figure is. The core checks the cooldown, the tile, its ## terrain, the tool tier and the free space in the inventory. @@ -431,6 +453,8 @@ func dig_at(tile: Vector2i) -> void: return session.dig(tile.x, tile.y) _next_dig_tick = session.tick() + GwSession.DIG_COOLDOWN_TICKS + if session.terrain_at(tile.x, tile.y) != GwSession.TERRAIN_GROUND: + map_view.hit(tile) ## Writes the running world as the save. Returns false if nothing was diff --git a/game/scripts/map_view.gd b/game/scripts/map_view.gd index f9913d0..4f47146 100644 --- a/game/scripts/map_view.gd +++ b/game/scripts/map_view.gd @@ -1,48 +1,182 @@ class_name MapView extends Node2D -## Draws the terrain of the shared world. The colours are placeholders until -## a style is chosen from the samples of plan M1d. +## Draws the terrain of the shared world in the style of `art`: every tile to +## fit its neighbours, the cracks of the hits it took, and a short burst of +## chips where a pickaxe lands. The map is drawn in square blocks, so that a +## hit draws only the blocks around its tile again. -const CRACK_COLOR := Color(0.1, 0.08, 0.06, 0.8) +## The terrain that rock masses and the edge of the map are made of. Tiles +## beside it are drawn to fit it. +const SOLID: Array[int] = [GwSession.TERRAIN_SOFT_ROCK, GwSession.TERRAIN_HARD_ROCK, GwSession.TERRAIN_BEDROCK] +## Tiles along each side of a block. +const BLOCK := 16 +## Seconds a burst of chips lasts. +const HIT_SECONDS := 0.35 var session: GwSession +var art: Art +var _width := 0 +var _height := 0 +var _terrain := PackedByteArray() +var _damage := PackedByteArray() +var _solid := PackedByteArray() +var _masks := PackedInt32Array() +var _variants := PackedInt32Array() +var _blocks: Array[MapBlock] = [] +var _bursts: Array[Dictionary] = [] +var _burst_layer := Node2D.new() -func _draw() -> void: +## A square part of the map that draws itself. +class MapBlock extends Node2D: + var view: MapView + var tiles: Rect2i + + func _init(owner_view: MapView, block_tiles: Rect2i) -> void: + view = owner_view + tiles = block_tiles + + func _draw() -> void: + view.draw_block(self, tiles) + + +func _ready() -> void: + _burst_layer.draw.connect(_draw_bursts) + add_child(_burst_layer) + set_process(false) + + +## The graphic of a terrain code. +static func id_of(terrain: int) -> StringName: + match terrain: + GwSession.TERRAIN_SOFT_ROCK: + return Art.SOFT_ROCK + GwSession.TERRAIN_HARD_ROCK: + return Art.HARD_ROCK + GwSession.TERRAIN_BEDROCK: + return Art.BEDROCK + return Art.GROUND + + +## Takes the whole map from `session` anew and draws it, or shows nothing if +## no world runs. Call it when a world starts or was replaced. +func reload() -> void: + for block in _blocks: + remove_child(block) + block.free() + _blocks.clear() + _bursts.clear() + _burst_layer.queue_redraw() if session == null or not session.is_running(): + _width = 0 + _height = 0 + return + _width = session.map_width() + _height = session.map_height() + _terrain = session.terrain_codes() + _damage = session.damage_codes() + _solid = TileShape.flags(_terrain, SOLID) + _masks.resize(_width * _height) + _variants.resize(_width * _height) + for y in _height: + for x in _width: + var tile := Vector2i(x, y) + _masks[y * _width + x] = TileShape.mask(_solid, _width, _height, tile, true) + _variants[y * _width + x] = TileShape.variant(tile) + for top in range(0, _height, BLOCK): + for left in range(0, _width, BLOCK): + var block := MapBlock.new(self, Rect2i(left, top, mini(BLOCK, _width - left), mini(BLOCK, _height - top))) + _blocks.append(block) + add_child(block) + move_child(_burst_layer, -1) + + +## Draws everything again, for example in another style. +func redraw() -> void: + for block in _blocks: + block.queue_redraw() + + +## Takes `tile` anew from the session after it changed, and draws it and the +## tiles around it again, which may have to fit it differently now. +func refresh_tile(tile: Vector2i) -> void: + if _blocks.is_empty() or not Rect2i(0, 0, _width, _height).has_point(tile): + return + _terrain = session.terrain_codes() + _damage = session.damage_codes() + var index := tile.y * _width + tile.x + _solid[index] = 1 if SOLID.has(_terrain[index]) else 0 + var touched: Array[MapBlock] = [] + for y in range(tile.y - 1, tile.y + 2): + for x in range(tile.x - 1, tile.x + 2): + var around := Vector2i(x, y) + if not Rect2i(0, 0, _width, _height).has_point(around): + continue + _masks[y * _width + x] = TileShape.mask(_solid, _width, _height, around, true) + var block := _block_of(around) + if not touched.has(block): + touched.append(block) + for block in touched: + block.queue_redraw() + + +## The block that draws `tile`. +func _block_of(tile: Vector2i) -> MapBlock: + var across := ceili(_width / float(BLOCK)) + @warning_ignore("integer_division") + return _blocks[(tile.y / BLOCK) * across + tile.x / BLOCK] + + +## Shows a burst of chips on `tile`, where a pickaxe just landed. +func hit(tile: Vector2i) -> void: + _bursts.append({"tile": tile, "age": 0.0}) + set_process(true) + + +## The number of blocks the map is drawn in. Tests read it. +func block_count() -> int: + return _blocks.size() + + +## The number of bursts still shown. Tests read it. +func burst_count() -> int: + return _bursts.size() + + +## The neighbour mask a tile is drawn with. Tests read it. +func mask_at(tile: Vector2i) -> int: + return _masks[tile.y * _width + tile.x] + + +## Draws the tiles `tiles` onto `block`, while the block draws. +func draw_block(block: MapBlock, tiles: Rect2i) -> void: + if art == null: return - var width := session.map_width() - var height := session.map_height() - var terrain := session.terrain_codes() - var damage := session.damage_codes() var size := GridMotion.TILE_SIZE - for y in height: - for x in width: - var index := y * width + x + for y in range(tiles.position.y, tiles.end.y): + for x in range(tiles.position.x, tiles.end.x): + var index := y * _width + x var rect := Rect2(x * size, y * size, size, size) - draw_rect(rect, color_of(terrain[index])) - if damage[index] > 0: - _draw_cracks(rect, damage[index]) + var variant := _variants[index] + art.draw(block, id_of(_terrain[index]), rect, {"solid": _masks[index], "variant": variant}) + if _damage[index] > 0: + art.draw(block, Art.CRACKS, rect, {"hits": _damage[index], "variant": variant}) -## The placeholder colour of a terrain code. -static func color_of(terrain: int) -> Color: - if terrain == GwSession.TERRAIN_GROUND: - return Color(0.42, 0.36, 0.28) - if terrain == GwSession.TERRAIN_SOFT_ROCK: - return Color(0.56, 0.55, 0.52) - if terrain == GwSession.TERRAIN_HARD_ROCK: - return Color(0.33, 0.33, 0.37) - if terrain == GwSession.TERRAIN_BEDROCK: - return Color(0.12, 0.12, 0.14) - return Color.MAGENTA +func _process(delta: float) -> void: + for burst in _bursts: + burst["age"] += delta / HIT_SECONDS + _bursts = _bursts.filter(func(burst: Dictionary) -> bool: return burst["age"] < 1.0) + if _bursts.is_empty(): + set_process(false) + _burst_layer.queue_redraw() -## One crack line for every hit the tile has taken. -func _draw_cracks(rect: Rect2, hits: int) -> void: - var inset := rect.grow(-3.0) - for line in hits: - var share := (line + 1.0) / (hits + 1.0) - var from := Vector2(inset.position.x, inset.position.y + inset.size.y * share) - var to := Vector2(inset.end.x, inset.position.y + inset.size.y * (1.0 - share)) - draw_line(from, to, CRACK_COLOR, 1.0) +func _draw_bursts() -> void: + if art == null: + return + var size := GridMotion.TILE_SIZE + for burst in _bursts: + var tile: Vector2i = burst["tile"] + var rect := Rect2(Vector2(tile) * size, Vector2(size, size)) + art.draw(_burst_layer, Art.HIT, rect, {"age": burst["age"], "variant": TileShape.variant(tile)}) diff --git a/game/scripts/tile_marker.gd b/game/scripts/tile_marker.gd index 123fc9b..ee2cb81 100644 --- a/game/scripts/tile_marker.gd +++ b/game/scripts/tile_marker.gd @@ -1,16 +1,14 @@ class_name TileMarker extends Node2D -## Outlines the tile under the mouse: green when the figure can reach it, -## red when it is too far away. - -const REACHABLE_COLOR := Color(0.4, 1.0, 0.4) -const UNREACHABLE_COLOR := Color(1.0, 0.4, 0.4) +## Marks the tile under the mouse, drawn by `art`: one way when the figure +## can reach it, another when it is too far away. var tile := Vector2i(-1, -1) var in_reach := false +var art: Art -## Moves the outline to `new_tile`. Redraws only when something changed. +## Moves the mark to `new_tile`. Redraws only when something changed. func show_tile(new_tile: Vector2i, reachable: bool) -> void: if new_tile == tile and reachable == in_reach: return @@ -20,6 +18,7 @@ func show_tile(new_tile: Vector2i, reachable: bool) -> void: func _draw() -> void: + if art == null: + return var size := GridMotion.TILE_SIZE - var color := REACHABLE_COLOR if in_reach else UNREACHABLE_COLOR - draw_rect(Rect2(Vector2(tile) * size, Vector2(size, size)), color, false, 1.0) + art.draw(self, Art.MARKER, Rect2(Vector2(tile) * size, Vector2(size, size)), {"reachable": in_reach}) diff --git a/game/tests/test_avatar.gd b/game/tests/test_avatar.gd index 87dbfa8..5716486 100644 --- a/game/tests/test_avatar.gd +++ b/game/tests/test_avatar.gd @@ -31,3 +31,26 @@ func test_without_a_map_the_figure_stays() -> void: avatar.walk(Vector2.LEFT, 0.1) check_eq(avatar.position, Vector2.ZERO, "no walkability callable") avatar.free() + + +func test_a_walking_figure_steps_and_faces_the_way_it_goes() -> void: + var avatar := Avatar.new() + avatar.is_walkable = _open_field + avatar.position = GridMotion.tile_center(Vector2i(50, 50)) + check(not avatar.moving, "standing at first") + avatar.walk(Vector2.LEFT, 0.1) + check(avatar.moving, "walking") + check_eq(avatar.facing, -1.0, "facing left") + check(avatar.step > 0.0, "the step turns") + avatar.walk(Vector2.UP, 0.1) + check_eq(avatar.facing, -1.0, "walking up keeps the way it faces") + avatar.walk(Vector2.ZERO, 0.1) + check(not avatar.moving, "standing again") + avatar.free() + + +func test_a_figure_stands_upright_in_its_tile() -> void: + var rect := Avatar.figure_rect(Vector2(100, 100)) + check_eq(rect.size, Vector2(GridMotion.TILE_SIZE, GridMotion.TILE_SIZE * 1.5), "a tile wide, one and a half tall") + check_eq(rect.get_center().x, 100.0, "centred on the figure") + check_eq(rect.end.y, 100.0 + Avatar.RADIUS + 1.0, "its feet just below its body") diff --git a/game/tests/test_figures.gd b/game/tests/test_figures.gd index 31cf682..522e7fa 100644 --- a/game/tests/test_figures.gd +++ b/game/tests/test_figures.gd @@ -32,3 +32,13 @@ func test_figures_can_be_forgotten() -> void: figures.clear() check_eq(figures.shown_at(3), Vector2.INF, "all are gone") figures.free() + + +func test_a_figure_faces_the_way_it_glides() -> void: + var figures := Figures.new() + figures.place(1, Vector2(100.0, 0.0)) + check_eq(figures.facing_of(1), 1.0, "facing right at first") + figures.place(1, Vector2.ZERO) + figures._process(0.02) + check_eq(figures.facing_of(1), -1.0, "facing left while it glides to the left") + figures.free() diff --git a/game/tests/test_main.gd b/game/tests/test_main.gd index 58653f6..5cf7f4e 100644 --- a/game/tests/test_main.gd +++ b/game/tests/test_main.gd @@ -381,3 +381,14 @@ func test_a_mistyped_address_is_reported() -> void: check(main.session == null, "nothing starts") check_eq(main.menu.status(), tr("ADDRESS_INVALID") % "host:port", "the menu says why") _clean_up([directory]) + + +func test_an_own_hit_shows_its_burst_at_once() -> void: + var directory := TestFiles.temporary_dir("burst") + var main := _start_main(directory) + main.play(false) + var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2) + check_eq(main.session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_SOFT_ROCK, "rock beside the clearing") + main.dig_at(rock) + check_eq(main.map_view.burst_count(), 1, "the burst shows before the hit lands in a tick") + _clean_up([directory]) diff --git a/game/tests/test_map_view.gd b/game/tests/test_map_view.gd new file mode 100644 index 0000000..da61fc7 --- /dev/null +++ b/game/tests/test_map_view.gd @@ -0,0 +1,94 @@ +extends TestCase +## The map view over a real world of the core. + +var key := PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]) + + +func _session() -> GwSession: + var session := GwSession.new() + session.start_new(7) + session.join(key, "Ada") + session.step() + return session + + +func _view(session: GwSession) -> MapView: + var view := MapView.new() + view.art = Art.new() + view.session = session + tree.root.add_child(view) + view.reload() + return view + + +func _free(view: MapView) -> void: + tree.root.remove_child(view) + view.free() + + +## The first tile of the soft rock beside the spawn clearing. +func _rock(session: GwSession) -> Vector2i: + return session.spawn_point() + Vector2i(7, -2) + + +func test_the_map_is_drawn_in_blocks_that_cover_it() -> void: + var session := _session() + var view := _view(session) + var across := ceili(session.map_width() / float(MapView.BLOCK)) + var down := ceili(session.map_height() / float(MapView.BLOCK)) + check_eq(view.block_count(), across * down, "blocks over the whole map") + for style in Art.style_keys(): + view.art.select(style) + view.redraw() + await tree.process_frame + _free(view) + + +func test_each_tile_is_drawn_to_fit_the_rock_around_it() -> void: + var session := _session() + var view := _view(session) + var solid := TileShape.flags(session.terrain_codes(), MapView.SOLID) + for tile: Vector2i in [Vector2i(0, 0), session.spawn_point(), _rock(session)]: + var expected := TileShape.mask(solid, session.map_width(), session.map_height(), tile, true) + check_eq(view.mask_at(tile), expected, "the neighbours of %s" % tile) + _free(view) + + +func test_a_broken_rock_no_longer_shapes_its_neighbours() -> void: + var session := _session() + var view := _view(session) + var rock := _rock(session) + var west := rock + Vector2i(-1, 0) + check(view.mask_at(west) & TileShape.E != 0, "the rock lies east of its neighbour") + for hit in 3: + session.dig(rock.x, rock.y) + session.step() + for wait in GwSession.DIG_COOLDOWN_TICKS: + session.step() + check_eq(session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "three hits break it") + view.refresh_tile(rock) + check(view.mask_at(west) & TileShape.E == 0, "now there is ground east of the neighbour") + await tree.process_frame + _free(view) + + +func test_a_burst_of_chips_shows_for_a_moment() -> void: + var view := _view(_session()) + view.hit(Vector2i(3, 3)) + check_eq(view.burst_count(), 1, "a burst") + view._process(MapView.HIT_SECONDS * 0.5) + check_eq(view.burst_count(), 1, "still in the air") + await tree.process_frame + view._process(MapView.HIT_SECONDS * 0.6) + check_eq(view.burst_count(), 0, "gone after its time") + _free(view) + + +func test_without_a_world_nothing_is_drawn() -> void: + var view := _view(_session()) + view.session = null + view.reload() + check_eq(view.block_count(), 0, "no blocks") + view.refresh_tile(Vector2i(1, 1)) + await tree.process_frame + _free(view) diff --git a/game/tests/test_map_view.gd.uid b/game/tests/test_map_view.gd.uid new file mode 100644 index 0000000..03c1808 --- /dev/null +++ b/game/tests/test_map_view.gd.uid @@ -0,0 +1 @@ +uid://bp0vlwfwtlh7h