Takt und Bewegung der Figur

TickClock rechnet Bildzeit in Takte um und holt nach einem Ruckler
höchstens fünf Takte auf. GridMotion bewegt einen Körper über das
Kachelraster und lässt ihn an Wänden entlanggleiten. Die Figur läuft
fünf Felder pro Sekunde, außerhalb des Gleichschritts.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven Wroblewski
2026-10-01 11:11:52 +02:00
parent bd0f588714
commit 2607df8a97
12 changed files with 220 additions and 0 deletions
+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