M1b: Brücke und Hülle #6

Merged
steven merged 21 commits from m1b-bruecke-und-huelle into main 2026-10-01 11:46:02 +00:00
12 changed files with 220 additions and 0 deletions
Showing only changes of commit 2607df8a97 - Show all commits
+31
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
uid://bpltng2n5f61g
+44
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dn0m8pxkigqig
+29
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bmrv5nfb0wu6r
+33
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://b7gc0kkgk5xg0
+46
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bjgyllbxp5ty8
+31
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bhwedt1ik28sq