diff --git a/game/scripts/avatar.gd b/game/scripts/avatar.gd new file mode 100644 index 0000000..cde223c --- /dev/null +++ b/game/scripts/avatar.gd @@ -0,0 +1,31 @@ +class_name Avatar +extends Node2D +## The local player's figure. It moves outside the lockstep simulation; only +## its pickaxe hits become commands. + +## Walking speed: five tiles per second. +const SPEED := 5.0 * GridMotion.TILE_SIZE +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 + +## Callable(x: int, y: int) -> bool that says whether a tile can be walked on. +var is_walkable: Callable + + +func _process(delta: float) -> void: + walk(Input.get_vector("move_left", "move_right", "move_up", "move_down"), delta) + + +## Moves the figure in `direction` for `delta` seconds, sliding along walls. +func walk(direction: Vector2, delta: float) -> void: + if direction == Vector2.ZERO or not is_walkable.is_valid(): + return + var motion := direction * SPEED * minf(delta, MAX_STEP_SECONDS) + position = GridMotion.move(position, motion, RADIUS, is_walkable) + + +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)) diff --git a/game/scripts/avatar.gd.uid b/game/scripts/avatar.gd.uid new file mode 100644 index 0000000..f54956f --- /dev/null +++ b/game/scripts/avatar.gd.uid @@ -0,0 +1 @@ +uid://bpltng2n5f61g diff --git a/game/scripts/grid_motion.gd b/game/scripts/grid_motion.gd new file mode 100644 index 0000000..75d9fc2 --- /dev/null +++ b/game/scripts/grid_motion.gd @@ -0,0 +1,44 @@ +class_name GridMotion +extends RefCounted +## Movement of a body over the tile grid. Figures move outside the lockstep +## simulation, so this code may use floating point. + +## Edge length of a tile, in pixels. +const TILE_SIZE := 16.0 + + +## The tile that contains `point`. +static func tile_of(point: Vector2) -> Vector2i: + return Vector2i(floori(point.x / TILE_SIZE), floori(point.y / TILE_SIZE)) + + +## The centre of `tile`, in pixels. +static func tile_center(tile: Vector2i) -> Vector2: + return (Vector2(tile) + Vector2(0.5, 0.5)) * TILE_SIZE + + +## True if a square body of half width `radius` around `center` touches +## only walkable tiles. `is_walkable` is a Callable(x: int, y: int) -> bool. +static func fits(center: Vector2, radius: float, is_walkable: Callable) -> bool: + var first := tile_of(center - Vector2(radius, radius)) + # The far edge lies just inside its tile, so a body that ends exactly on + # a tile border does not count the next tile. + var last := tile_of(center + Vector2(radius, radius) - Vector2(0.001, 0.001)) + for y in range(first.y, last.y + 1): + for x in range(first.x, last.x + 1): + if not is_walkable.call(x, y): + return false + return true + + +## Moves a body by `motion`, one axis after the other. An axis that would +## end in a wall is skipped, so a body pushed into a wall slides along it. +static func move(center: Vector2, motion: Vector2, radius: float, is_walkable: Callable) -> Vector2: + var result := center + var along_x := result + Vector2(motion.x, 0.0) + if fits(along_x, radius, is_walkable): + result = along_x + var along_y := result + Vector2(0.0, motion.y) + if fits(along_y, radius, is_walkable): + result = along_y + return result diff --git a/game/scripts/grid_motion.gd.uid b/game/scripts/grid_motion.gd.uid new file mode 100644 index 0000000..148586e --- /dev/null +++ b/game/scripts/grid_motion.gd.uid @@ -0,0 +1 @@ +uid://dn0m8pxkigqig diff --git a/game/scripts/tick_clock.gd b/game/scripts/tick_clock.gd new file mode 100644 index 0000000..cd89d18 --- /dev/null +++ b/game/scripts/tick_clock.gd @@ -0,0 +1,29 @@ +class_name TickClock +extends RefCounted +## Turns frame time into a whole number of simulation ticks. + +## Most ticks one frame may run. After a long stall the simulation catches +## up only this far and drops the rest, instead of freezing to catch up. +const MAX_TICKS_PER_FRAME := 5 + +var seconds_per_tick: float +var paused := false + +var _accumulated := 0.0 + + +func _init(ticks_per_second: int) -> void: + seconds_per_tick = 1.0 / ticks_per_second + + +## Returns how many ticks to run for a frame that took `delta` seconds. +func advance(delta: float) -> int: + if paused: + return 0 + _accumulated += delta + var ticks := int(_accumulated / seconds_per_tick) + if ticks > MAX_TICKS_PER_FRAME: + _accumulated = 0.0 + return MAX_TICKS_PER_FRAME + _accumulated -= ticks * seconds_per_tick + return ticks diff --git a/game/scripts/tick_clock.gd.uid b/game/scripts/tick_clock.gd.uid new file mode 100644 index 0000000..717fbf3 --- /dev/null +++ b/game/scripts/tick_clock.gd.uid @@ -0,0 +1 @@ +uid://bmrv5nfb0wu6r diff --git a/game/tests/test_avatar.gd b/game/tests/test_avatar.gd new file mode 100644 index 0000000..87dbfa8 --- /dev/null +++ b/game/tests/test_avatar.gd @@ -0,0 +1,33 @@ +extends TestCase + + +func _open_field(x: int, y: int) -> bool: + return x >= 0 and y >= 0 and x < 100 and y < 100 + + +func test_the_figure_walks_five_tiles_per_second() -> void: + var avatar := Avatar.new() + avatar.is_walkable = _open_field + avatar.position = GridMotion.tile_center(Vector2i(50, 50)) + var start := avatar.position + for frame in 10: + avatar.walk(Vector2.RIGHT, 0.1) + check(is_equal_approx(avatar.position.x - start.x, 5.0 * GridMotion.TILE_SIZE), "five tiles in one second, moved %s" % (avatar.position.x - start.x)) + avatar.free() + + +func test_a_long_frame_moves_the_figure_no_further_than_a_tenth_of_a_second() -> void: + var avatar := Avatar.new() + avatar.is_walkable = _open_field + avatar.position = GridMotion.tile_center(Vector2i(50, 50)) + var start := avatar.position + avatar.walk(Vector2.DOWN, 3.0) + check(is_equal_approx(avatar.position.y - start.y, Avatar.SPEED * Avatar.MAX_STEP_SECONDS), "cut to a tenth of a second") + avatar.free() + + +func test_without_a_map_the_figure_stays() -> void: + var avatar := Avatar.new() + avatar.walk(Vector2.LEFT, 0.1) + check_eq(avatar.position, Vector2.ZERO, "no walkability callable") + avatar.free() diff --git a/game/tests/test_avatar.gd.uid b/game/tests/test_avatar.gd.uid new file mode 100644 index 0000000..41aa696 --- /dev/null +++ b/game/tests/test_avatar.gd.uid @@ -0,0 +1 @@ +uid://b7gc0kkgk5xg0 diff --git a/game/tests/test_grid_motion.gd b/game/tests/test_grid_motion.gd new file mode 100644 index 0000000..569ef0c --- /dev/null +++ b/game/tests/test_grid_motion.gd @@ -0,0 +1,46 @@ +extends TestCase +## A 10 x 10 test grid in which tile (3, 1) and every tile outside are walls. + +const SIZE := GridMotion.TILE_SIZE +const RADIUS := 5.0 + + +func _walkable(x: int, y: int) -> bool: + if x < 0 or y < 0 or x >= 10 or y >= 10: + return false + return Vector2i(x, y) != Vector2i(3, 1) + + +func test_tile_of_and_tile_center_agree() -> void: + check_eq(GridMotion.tile_of(Vector2(0.0, 0.0)), Vector2i(0, 0), "origin") + check_eq(GridMotion.tile_of(Vector2(15.9, 16.0)), Vector2i(0, 1), "tile borders") + check_eq(GridMotion.tile_of(Vector2(-0.1, 3.0)), Vector2i(-1, 0), "left of the map") + check_eq(GridMotion.tile_center(Vector2i(2, 3)), Vector2(40.0, 56.0), "centre") + check_eq(GridMotion.tile_of(GridMotion.tile_center(Vector2i(7, 4))), Vector2i(7, 4), "round trip") + + +func test_free_movement_covers_the_full_distance() -> void: + var start := GridMotion.tile_center(Vector2i(5, 5)) + var moved := GridMotion.move(start, Vector2(3.0, -2.0), RADIUS, _walkable) + check_eq(moved, start + Vector2(3.0, -2.0), "unblocked") + + +func test_a_wall_stops_the_body_before_it() -> void: + var position := GridMotion.tile_center(Vector2i(1, 1)) + for step in 40: + position = GridMotion.move(position, Vector2(1.0, 0.0), RADIUS, _walkable) + check(position.x + RADIUS <= 3.0 * SIZE, "the body stays left of the wall at x = 48, ends at %s" % position.x) + check(position.x + RADIUS > 3.0 * SIZE - 1.0, "and gets close to it, ends at %s" % position.x) + + +func test_a_body_slides_along_a_wall() -> void: + var start := GridMotion.tile_center(Vector2i(2, 1)) + var moved := GridMotion.move(start, Vector2(4.0, 2.0), RADIUS, _walkable) + check_eq(moved.x, start.x, "the blocked axis stays") + check_eq(moved.y, start.y + 2.0, "the free axis moves") + + +func test_the_edge_of_the_map_is_a_wall() -> void: + var start := Vector2(RADIUS, RADIUS) + var moved := GridMotion.move(start, Vector2(-1.0, -1.0), RADIUS, _walkable) + check_eq(moved, start, "no step off the map") diff --git a/game/tests/test_grid_motion.gd.uid b/game/tests/test_grid_motion.gd.uid new file mode 100644 index 0000000..9ac22a8 --- /dev/null +++ b/game/tests/test_grid_motion.gd.uid @@ -0,0 +1 @@ +uid://bjgyllbxp5ty8 diff --git a/game/tests/test_tick_clock.gd b/game/tests/test_tick_clock.gd new file mode 100644 index 0000000..5c7f463 --- /dev/null +++ b/game/tests/test_tick_clock.gd @@ -0,0 +1,31 @@ +extends TestCase + + +func test_one_tick_lasts_a_twentieth_of_a_second() -> void: + var clock := TickClock.new(20) + check_eq(clock.advance(0.05), 1, "a full tick") + check_eq(clock.advance(0.025), 0, "half a tick") + check_eq(clock.advance(0.025), 1, "the second half completes the tick") + + +func test_a_second_of_short_frames_runs_about_twenty_ticks() -> void: + var clock := TickClock.new(20) + var ticks := 0 + for frame in 60: + ticks += clock.advance(1.0 / 60.0) + # Rounding in floating point may leave the last tick for the next frame. + check(ticks == 19 or ticks == 20, "19 or 20 ticks, got %d" % ticks) + + +func test_a_long_stall_runs_at_most_five_ticks_and_forgets_the_rest() -> void: + var clock := TickClock.new(20) + check_eq(clock.advance(10.0), TickClock.MAX_TICKS_PER_FRAME, "capped") + check_eq(clock.advance(0.0), 0, "the rest of the stall is dropped") + + +func test_a_paused_clock_runs_no_ticks() -> void: + var clock := TickClock.new(20) + clock.paused = true + check_eq(clock.advance(1.0), 0, "paused") + clock.paused = false + check_eq(clock.advance(0.05), 1, "running again") diff --git a/game/tests/test_tick_clock.gd.uid b/game/tests/test_tick_clock.gd.uid new file mode 100644 index 0000000..d61e09a --- /dev/null +++ b/game/tests/test_tick_clock.gd.uid @@ -0,0 +1 @@ +uid://bhwedt1ik28sq