M1b: Brücke und Hülle #6
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bpltng2n5f61g
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dn0m8pxkigqig
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bmrv5nfb0wu6r
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b7gc0kkgk5xg0
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bjgyllbxp5ty8
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bhwedt1ik28sq
|
||||||
Reference in New Issue
Block a user