Initial commit: Transcience MVP
Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated server with client-side prediction. Clients send input only; the server resolves every hit for both players and enemies (no PvP). - SimWorld: whole simulation as plain RefCounted objects (no nodes, no physics server), ~0.24ms/tick at peak load -- runs headless for free and drives 78 tests in under a second - BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn events rather than per-tick state - Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash enemies and bosses -- a new boss is data in src/content/content.gd, no simulation changes - The Warden of the Fold: stationary 4-phase boss built entirely on that format - Lobby hub with a portal into on-demand dungeon instances; one process hosts the hub plus every concurrent dungeon - Emergency escape: 3s server-owned channel, cancelled by damage - tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over ENet), bench.gd; git hooks wired to the same scripts - docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
extends SceneTree
|
||||
## Headless performance probe for the simulation alone -- no transport, no
|
||||
## rendering. Answers the only question that decides whether the design holds:
|
||||
## how many ticks per second can one dedicated server sustain per instance, and
|
||||
## how big does the bullet field get?
|
||||
##
|
||||
## godot --headless --path . --script tools/bench.gd
|
||||
## godot --headless --path . --script tools/bench.gd -- --players 4 --ticks 5400
|
||||
|
||||
var players: int = 4
|
||||
var ticks: int = 3600
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_parse()
|
||||
print("bench: %d players, %d ticks (%.1f s of play)"
|
||||
% [players, ticks, float(ticks) / float(SimConfig.TICK_RATE)])
|
||||
_run("boss fight", _make_boss_world())
|
||||
_run("trash wave", _make_wave_world())
|
||||
quit(0)
|
||||
|
||||
|
||||
func _parse() -> void:
|
||||
var argv := OS.get_cmdline_user_args()
|
||||
var i := 0
|
||||
while i < argv.size():
|
||||
match argv[i]:
|
||||
"--players":
|
||||
i += 1
|
||||
players = int(argv[i])
|
||||
"--ticks":
|
||||
i += 1
|
||||
ticks = int(argv[i])
|
||||
i += 1
|
||||
|
||||
|
||||
func _add_players(world: SimWorld) -> void:
|
||||
for n in players:
|
||||
var p := world.add_player(n + 2, "bench%d" % n)
|
||||
p.pos = Vector2(-200.0 + 100.0 * float(n), 180.0)
|
||||
|
||||
|
||||
func _make_boss_world() -> SimWorld:
|
||||
var world := SimWorld.new(1234)
|
||||
_add_players(world)
|
||||
world.spawn_boss(Content.warden())
|
||||
return world
|
||||
|
||||
|
||||
func _make_wave_world() -> SimWorld:
|
||||
var world := SimWorld.new(4321)
|
||||
_add_players(world)
|
||||
for i in 6:
|
||||
world.spawn_enemy(Content.turret(), Vector2(-400.0 + 160.0 * float(i), -220.0), i * 20)
|
||||
for i in 4:
|
||||
world.spawn_enemy(Content.drifter(), Vector2(-240.0 + 160.0 * float(i), -80.0), i * 25)
|
||||
return world
|
||||
|
||||
|
||||
func _run(label: String, world: SimWorld) -> void:
|
||||
var peak := 0
|
||||
var total := 0
|
||||
var start := Time.get_ticks_usec()
|
||||
for t in ticks:
|
||||
for peer in world.players:
|
||||
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1,
|
||||
Vector2(cos(float(t) * 0.02), sin(float(t) * 0.031)),
|
||||
float(t) * 0.05, InputFrame.BTN_FIRE)]
|
||||
world.queue_input(peer, frames)
|
||||
world.step()
|
||||
world.drain_events()
|
||||
if world.boss != null:
|
||||
# Sweep hp downwards instead of letting the boss die, so the run
|
||||
# covers every phase including the heaviest one.
|
||||
world.boss.hp = int(float(world.boss.def.max_hp)
|
||||
* (0.95 - 0.9 * float(t) / float(ticks)))
|
||||
peak = maxi(peak, world.pool.live_count)
|
||||
total += world.pool.live_count
|
||||
var elapsed := float(Time.get_ticks_usec() - start) / 1_000_000.0
|
||||
var per_tick_ms := elapsed / float(ticks) * 1000.0
|
||||
print(" %-12s %7.3f ms/tick peak %4d bullets avg %4d headroom x%.1f"
|
||||
% [label, per_tick_ms, peak, total / ticks,
|
||||
(1000.0 / float(SimConfig.TICK_RATE)) / maxf(per_tick_ms, 0.0001)])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcga32vht26cr
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Parse-check every script in the project. ~5s, so run it after every edit --
|
||||
# it is the fastest signal that a change is syntactically and type-wise sound.
|
||||
#
|
||||
# Two stages, both necessary:
|
||||
# 1. --import refreshes .godot/global_script_class_cache.cfg. Without it, a
|
||||
# newly added `class_name` is invisible and every use of it reports
|
||||
# "Identifier not declared", which looks like a real error but is not.
|
||||
# 2. check.tscn loads every script so the engine prints real parse errors.
|
||||
# Set SKIP_IMPORT=1 to skip stage 1 when you have not added a class_name.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
GODOT="${GODOT:-godot}"
|
||||
|
||||
if [[ "${SKIP_IMPORT:-0}" != "1" ]]; then
|
||||
"$GODOT" --headless --path . --import >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
out=$("$GODOT" --headless --path . res://tools/check.tscn 2>&1)
|
||||
|
||||
if ! grep -q "CHECK_COMPLETE" <<<"$out"; then
|
||||
echo "check.tscn did not finish -- engine output follows:" >&2
|
||||
echo "$out" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
problems=$(grep -E "Parse Error|Failed to load script|does not inherit|SCRIPT ERROR" <<<"$out" || true)
|
||||
if [[ -n "$problems" ]]; then
|
||||
grep -E -A2 "Parse Error|Failed to load script|does not inherit|SCRIPT ERROR" <<<"$out"
|
||||
echo
|
||||
echo "FAIL: $(wc -l <<<"$problems") problem line(s)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -o 'CHECK_COMPLETE scripts=[0-9]*' <<<"$out" | sed 's/CHECK_COMPLETE /clean, /'
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tools/check_scripts.gd" id="1"]
|
||||
|
||||
[node name="Check" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,48 @@
|
||||
extends Node
|
||||
## Parse every GDScript in the project so the engine prints any parse or type
|
||||
## error, then quit.
|
||||
##
|
||||
## Run as a scene (not with --script) because autoload identifiers such as `Net`
|
||||
## are only registered when the engine boots a real main loop; a --script run
|
||||
## reports false "Identifier not found" errors for every file that uses one.
|
||||
##
|
||||
## godot --headless --path . res://tools/check.tscn
|
||||
##
|
||||
## `tools/check.sh` wraps this and owns the exit code: a script that fails to
|
||||
## parse can still come back from ResourceLoader as a non-null object, so the
|
||||
## engine's own stderr is the reliable signal.
|
||||
|
||||
const SKIP_DIRS := ["res://addons", "res://.godot"]
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var scripts := _collect("res://")
|
||||
scripts.sort()
|
||||
for path in scripts:
|
||||
ResourceLoader.load(path, "Script")
|
||||
print("CHECK_COMPLETE scripts=%d" % scripts.size())
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
func _collect(dir_path: String) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
for skip in SKIP_DIRS:
|
||||
if dir_path.begins_with(skip):
|
||||
return out
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
return out
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if entry.begins_with("."):
|
||||
entry = dir.get_next()
|
||||
continue
|
||||
var full := dir_path.path_join(entry)
|
||||
if dir.current_is_dir():
|
||||
out.append_array(_collect(full))
|
||||
elif entry.ends_with(".gd"):
|
||||
out.append(full)
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return out
|
||||
@@ -0,0 +1 @@
|
||||
uid://gdilmx7e78su
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run a windowed client. With no arguments it opens the connect menu.
|
||||
# tools/client.sh --join --name ada # skip the menu
|
||||
# tools/client.sh --join --host 10.0.0.5
|
||||
# tools/client.sh --listen # listen server: host and play
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
GODOT="${GODOT:-godot}"
|
||||
exec "$GODOT" --path . -- "$@"
|
||||
@@ -0,0 +1,36 @@
|
||||
extends SceneTree
|
||||
## Writes .tres copies of everything in [Content] into res://resources/, so the
|
||||
## numbers can be tuned in the editor inspector.
|
||||
##
|
||||
## godot --headless --path . --script tools/export_content.gd
|
||||
##
|
||||
## These files are an export, not the source of truth: [Content] is. Re-running
|
||||
## this overwrites them. If you tune a value in the inspector and want to keep
|
||||
## it, port it back into src/content/content.gd -- otherwise the next export
|
||||
## silently reverts your change.
|
||||
|
||||
const OUT_ENEMIES := "res://resources/enemies"
|
||||
const OUT_BOSSES := "res://resources/bosses"
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
DirAccess.make_dir_recursive_absolute(OUT_ENEMIES)
|
||||
DirAccess.make_dir_recursive_absolute(OUT_BOSSES)
|
||||
var written := 0
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER,
|
||||
Content.ENEMY_DUMMY]:
|
||||
written += _save(Content.enemy(id), "%s/%s.tres" % [OUT_ENEMIES, id])
|
||||
for id in [Content.BOSS_WARDEN]:
|
||||
written += _save(Content.boss(id), "%s/%s.tres" % [OUT_BOSSES, id])
|
||||
print("exported %d resources" % written)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _save(res: Resource, path: String) -> int:
|
||||
# Sub-resources (the emitters) are inlined so one file is one whole enemy.
|
||||
var err := ResourceSaver.save(res, path)
|
||||
if err != OK:
|
||||
printerr("failed to write %s (error %d)" % [path, err])
|
||||
return 0
|
||||
print(" ", path)
|
||||
return 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7gfvfowtnq2r
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time setup after cloning: point git at the hooks committed in .githooks/.
|
||||
# git deliberately never runs hooks from a fresh clone on its own (that would
|
||||
# be arbitrary code execution on `git clone`), so this is the one manual step.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
git config core.hooksPath .githooks
|
||||
echo "hooks installed: pre-commit (check + test), pre-push (smoke)"
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the dedicated server. Everything after -- is parsed by GameOpts.
|
||||
# tools/server.sh # port 27015, headless
|
||||
# tools/server.sh --port 27020 --verbose
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
GODOT="${GODOT:-godot}"
|
||||
exec "$GODOT" --headless --path . -- --server "$@"
|
||||
@@ -0,0 +1,48 @@
|
||||
extends SceneTree
|
||||
## Writes the project's input map into project.godot.
|
||||
##
|
||||
## Hand-editing the [input] section is error-prone -- the events are serialised
|
||||
## engine objects with a long property list. Generating them through the real
|
||||
## API means the file is always in the format the engine expects.
|
||||
##
|
||||
## godot --headless --path . --script tools/setup_input_map.gd
|
||||
##
|
||||
## Re-run after changing the bindings below. Physical keycodes are used so the
|
||||
## bindings follow key position rather than layout.
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_action("move_up", [_key(KEY_W), _key(KEY_UP)])
|
||||
_action("move_down", [_key(KEY_S), _key(KEY_DOWN)])
|
||||
_action("move_left", [_key(KEY_A), _key(KEY_LEFT)])
|
||||
_action("move_right", [_key(KEY_D), _key(KEY_RIGHT)])
|
||||
_action("fire", [_mouse(MOUSE_BUTTON_LEFT), _key(KEY_SPACE)])
|
||||
_action("emergency_escape", [_key(KEY_F)])
|
||||
_action("interact", [_key(KEY_E)])
|
||||
var err := ProjectSettings.save()
|
||||
print("input map written, err=%d" % err)
|
||||
quit(0 if err == OK else 1)
|
||||
|
||||
|
||||
func _action(name: String, events: Array) -> void:
|
||||
ProjectSettings.set_setting("input/" + name, {
|
||||
"deadzone": 0.2,
|
||||
"events": events,
|
||||
})
|
||||
|
||||
|
||||
## device -1 (DEVICE_ID_EMULATION) is what the editor writes and is the only
|
||||
## value that matches input from a real device. A freshly constructed event
|
||||
## defaults to 16, which silently matches nothing.
|
||||
func _key(code: Key) -> InputEventKey:
|
||||
var e := InputEventKey.new()
|
||||
e.device = -1
|
||||
e.physical_keycode = code
|
||||
return e
|
||||
|
||||
|
||||
func _mouse(button: MouseButton) -> InputEventMouseButton:
|
||||
var e := InputEventMouseButton.new()
|
||||
e.device = -1
|
||||
e.button_index = button
|
||||
return e
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd7ich1hfc40u
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end integration test: boot a dedicated server, connect two scripted
|
||||
# bot clients over a real ENet socket, and assert that the whole loop happened --
|
||||
# handshake, lobby, portal into a dungeon, and the emergency escape back.
|
||||
#
|
||||
# This is the test that catches everything unit tests structurally cannot: RPC
|
||||
# wiring, codec round-trips over the wire, instance transfers, and the client's
|
||||
# reconciliation loop. Run it before calling any networking change done.
|
||||
#
|
||||
# tools/smoke.sh # ~35s
|
||||
# KEEP=1 tools/smoke.sh # keep the logs and print where they are
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
GODOT="${GODOT:-godot}"
|
||||
PORT="${PORT:-27099}"
|
||||
SERVER_TICKS="${SERVER_TICKS:-2000}"
|
||||
CLIENT_TICKS="${CLIENT_TICKS:-1700}"
|
||||
OUT="$(mktemp -d -t transcience-smoke-XXXXXX)"
|
||||
|
||||
cleanup() {
|
||||
kill %1 %2 %3 2>/dev/null
|
||||
wait 2>/dev/null
|
||||
if [[ "${KEEP:-0}" == "1" ]]; then
|
||||
echo "logs kept in $OUT"
|
||||
else
|
||||
rm -rf "$OUT"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "smoke: server on port $PORT, logs in $OUT"
|
||||
"$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \
|
||||
> "$OUT/server.log" 2>&1 &
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null && break
|
||||
sleep 0.25
|
||||
done
|
||||
if ! grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null; then
|
||||
echo "FAIL: server never became ready"; cat "$OUT/server.log"; exit 1
|
||||
fi
|
||||
|
||||
for n in 1 2; do
|
||||
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
||||
--name "bot$n" --autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 &
|
||||
sleep 0.4
|
||||
done
|
||||
|
||||
wait %2 %3 2>/dev/null
|
||||
wait %1 2>/dev/null
|
||||
|
||||
fails=0
|
||||
check() { # check <label> <file> <pattern>
|
||||
if grep -qE "$3" "$2"; then
|
||||
echo " ok $1"
|
||||
else
|
||||
echo " FAIL $1 (no match for /$3/ in $(basename "$2"))"
|
||||
fails=$((fails + 1))
|
||||
fi
|
||||
}
|
||||
refute() { # refute <label> <file> <pattern>
|
||||
local hits
|
||||
hits=$(grep -cE "$3" "$2" || true)
|
||||
if [[ "$hits" == "0" ]]; then
|
||||
echo " ok $1"
|
||||
else
|
||||
echo " FAIL $1 ($hits line(s) matched /$3/ in $(basename "$2"))"
|
||||
grep -E "$3" "$2" | head -5 | sed 's/^/ /'
|
||||
fails=$((fails + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "assertions:"
|
||||
check "server accepted bot1" "$OUT/server.log" "joined as 'bot1'"
|
||||
check "server accepted bot2" "$OUT/server.log" "joined as 'bot2'"
|
||||
check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance"
|
||||
check "emergency escape completed" "$OUT/server.log" "escaped to lobby"
|
||||
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
|
||||
check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"
|
||||
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
||||
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
||||
|
||||
echo
|
||||
if [[ $fails -eq 0 ]]; then
|
||||
echo "SMOKE PASS"
|
||||
else
|
||||
echo "SMOKE FAIL ($fails)"
|
||||
echo "--- server.log (tail) ---"; tail -40 "$OUT/server.log"
|
||||
echo "--- bot1.log (tail) ---"; tail -40 "$OUT/bot1.log"
|
||||
fi
|
||||
exit $fails
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the GUT suite headlessly.
|
||||
# tools/test.sh # everything under tests/
|
||||
# tools/test.sh -gtest=res://tests/unit/test_bullet_pool.gd
|
||||
# tools/test.sh -gunit_test_name=test_ring_is_evenly_spaced
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
GODOT="${GODOT:-godot}"
|
||||
|
||||
if [[ "${SKIP_IMPORT:-0}" != "1" ]]; then
|
||||
"$GODOT" --headless --path . --import >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
args=("-gdir=res://tests" "-ginclude_subdirs" "-gexit")
|
||||
if [[ $# -gt 0 ]]; then
|
||||
args=("$@" "-gexit")
|
||||
fi
|
||||
|
||||
"$GODOT" --headless --path . -s addons/gut/gut_cmdln.gd "${args[@]}"
|
||||
Reference in New Issue
Block a user