OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / map_flat.gd

144 lines · gd ref:
extends "res://scenes/maps/map_dc_level.gd"
## Flat testbed — one very large plane, nothing else.
##
## WHY THIS MAP EXISTS. Two reasons, and neither is "a simple map would be nice".
##
## 1. A NAVMESH THAT BAKES IN ONE CALL. Bots need pathfinding; baking a navmesh
##    over DC terrain is its own problem (per-chunk regions, async bakes, and
##    NavMap::sync is O(N^2) on edge connectivity -- godot#96483). A plane is the
##    control case: if an agent cannot reach a goal here, the bug is in the agent,
##    not in the navmesh.
##
## 2. A CHEAP WORLD FOR A LONG SOAK. A terrain map measured 1400% CPU / 669MB on
##    a headless peer (spec/daemon-session.md 2a) because CPU dual-contouring
##    meshes a world nobody renders. Two sessions running for hours need a world
##    that costs nothing.
##
## ⚠ SETTING THIS AS THE MAP IS NOT ENOUGH ON ITS OWN, and the reason is worth
## knowing before you trust a low CPU number. game.gd:115 swaps to a terrain-free
## map when headless, but game.gd:564 change_map() has NO headless guard, and
## _restore_saved_map() loads whatever world:settings:current_map says. So a peer
## joining a room adopts the ROOM's map regardless of that swap. To get a cheap
## session the ROOM's persisted map must be this one -- see spec/bot-testbed.md.
##
## ⛔ THIS MAP MUST NOT OWN A WorldEnvironment. game.gd:637 detaches game.gd's
## Sky3D whenever a map has one, and sky_debug_panel is bound to that node ONCE
## (game.gd:291) with no rebind -- so the Atmosphere sliders then drive a detached
## node and nothing moves. Measured on this map: the slider reaches
## Sky3D.gd:136 -> TimeOfDay.gd:412 -> SkyDome.gd:497 -> get_global_transform()
## -> ERROR "!is_inside_tree()". See the block in map_flat.tscn.
##
## The opening time is seeded as MAP-SCOPED STATE instead: `world:sky:` carries
## map_scope in game_state.gd:92-93, so it stores as
## map:map_flat:world:sky:current_time and is this map's own. The panel is the
## thing that applies sky params (_apply_external -> slider -> _on_slider_changed),
## so writing state talks to the authority instead of fighting it -- which is what
## both earlier attempts did, one by writing the node the panel owns and one by
## taking the node away from it.
##
## OLD NOTE, kept because it is still true of maps that DO own a sky:
## game.gd:637 detaches its own Sky3D the moment a map has its own
## WorldEnvironment, and re-attaches it for a map that has none -- which is why
## map_test is nighttime, inheriting game.tscn's authored current_time = 19.6.
## So the opening time is authored on the Sky3DExtended node in map_flat.tscn
## (11.0, on both the wrapper and TimeOfDay), NOT set from code here. Writing
## current_time against /root/Game/Sky3DExtended would mutate the SHARED sky that
## every map without its own inherits, and would miss this map entirely once
## game.gd detached it.

## Half-extent of the plane in metres. The plane spans EXTENT x EXTENT centred on
## the origin, with its walkable surface at y = 0.
##
## Exported so the testbed can grow without editing the scene. Applied in _ready
## rather than baked into the .tscn so both stay in one place; the nodes
## themselves are declared in the scene because map_core._ready() walks children
## to add the grapple collision layer, and anything created after that call would
## silently miss it.
@export var extent: float = 4096.0

## Thickness of the collision slab under the surface. Only needs to be enough
## that nothing tunnels through it; the surface is what matters.
@export var slab_thickness: float = 2.0



func _ready() -> void:
	# FIRST, per map_core's contract: subclasses call super._ready() and configure
	# afterwards. It adds the grapple layer and publishes required assets.
	super._ready()
	_apply_extent()
	# EAGERLY, not on first use: _ensure_persistence is what subscribes to
	# GameState.state_changed, so creating it lazily would mean this peer did not
	# replay another peer's authored objects until it happened to author one
	# itself. map_dc_level learned the same thing and says so at its call site.
	_ensure_persistence()
	# (Bot navigation is built by map_dc_level for every level map, this one included.)
	# Deferred: GameState.enter_map() fires map_changing BEFORE this map is
	# instantiated, and the panel resets every sky prop to its scene default on
	# that signal. Seeding inside _ready would be overwritten by a reset that has
	# already been queued; deferring puts the write after it.
	_seed_opening_sky.call_deferred()


## Resize the declared nodes to `extent`. Idempotent.
##
## The collision box is a BoxShape3D, NOT a WorldBoundaryShape3D. A world boundary
## is an infinite half-space: cheaper, and wrong here, because a navmesh bake needs
## actual source geometry and an infinite plane gives it nothing to voxelize.
func _apply_extent() -> void:
	var mesh_node := get_node_or_null(^"Ground/Surface") as MeshInstance3D
	if mesh_node != null and mesh_node.mesh is PlaneMesh:
		var pm := mesh_node.mesh as PlaneMesh
		pm.size = Vector2(extent, extent)

	var shape_node := get_node_or_null(^"Ground/Collision") as CollisionShape3D
	if shape_node != null and shape_node.shape is BoxShape3D:
		var bs := shape_node.shape as BoxShape3D
		bs.size = Vector3(extent, slab_thickness, extent)
		# Surface flush with y = 0: the slab hangs entirely below it.
		shape_node.position = Vector3(0.0, -slab_thickness * 0.5, 0.0)


## Open at OPENING_TIME unless this map already has a sky someone authored.
##
## SAVED WINS (hyper-games #42 P1). SkyDefaults answers "is this prop unsaved",
## and because world:sky: is map-scoped, keys_with_prefix returns THIS map's keys
## -- so the question is per-map for free and the write seeds only map_flat.
const OPENING_TIME := 11.0

func _seed_opening_sky() -> void:
	if GameState == null:
		return
	var keys: Array[String] = GameState.keys_with_prefix(SkyDefaults.PREFIX)
	if SkyDefaults.unsaved(["current_time"] as Array[String], keys).is_empty():
		print("[MapFlat] sky: kept this map's saved current_time")
		return
	GameState.set_state(SkyDefaults.PREFIX + "current_time", OPENING_TIME)
	print("[MapFlat] sky: seeded current_time=%.1f for this map" % OPENING_TIME)



# ---- authored objects AND brushes -------------------------------------------
#
# This map used to carry its own journal + SceneObjects pair with `level = null`:
# a builder bot needs a journal, a prefab catalogue and a fold, not brushes, and
# map_dc_level was the only map wiring them. Since 2026-10-02 (user decision:
# brush building + textures on every demo map) the map extends map_dc_level with
# level_import OFF -- an EMPTY brush level -- so the journal, SceneObjects,
# the "level" admin surface and create_object() are INHERITED, and the journal
# folds brushes into this map's DCLevel as well as objects. One pair, never two:
# a second one would mean two managers folding one journal.
#
# Bot navigation (the graph, its solid set incl. brushes, rebuilds) is map_dc_level's,
# shared by every level map. This map only fixes where the graph lies:


## The slab is far larger than anywhere a bot goes, and the solid-bounds default would
## cube the whole slab (capped at NAV_MAX_SPAN). The bot area: 64 m, 4 chunks of 16 m
## at 1 m cells -- what this map always used.
const NAV_REGION := AABB(Vector3(-32, -8, -32), Vector3(64, 64, 64))


func _nav_region(_centres: PackedVector3Array, _halves: PackedVector3Array) -> AABB:
	return NAV_REGION