Commit
Theria
feat: hand-designed axial 3v3 map, field models, LoL pacing
modified .gitignore
@@ -15,3 +15,7 @@ ajhahnde/
build/
dist/
export_presets.cfg
# Map editor still-snapshot output (regenerated by tools/map_shot.gd; not source)
map_preview.png
map_preview.png.import
modified CHANGELOG.md
@@ -26,6 +26,24 @@ protocol version.
### Changed
- The arena is now a hand-designed map laid out on a single diagonal axis of symmetry rather
than the placeholder square diamond. The two lanes each bow out to one side of the map, a
river meanders across the middle, and the jungle holds two shared neutral camps on the axis
plus mirrored side camps. Each team defends four towers — two ringing its nexus and two
forward down the lanes — and the nexus itself. The whole map mirrors across the team-base
diagonal — one team's geometry is the other's reflected — so neither side has a positional
edge. The arena is sized to about 65% of a standard 5v5 map's side length — a tighter 3v3
footprint — and the movement speeds are tuned to match, so heroes and creeps move at a
comparable pace and a lane takes a standard-MOBA walk to cross rather than a sprint. The
simulation, the bots, and the netcode read the same geometry data, so the change is layout
and tuning only; the protocol is unchanged.
- The lane creeps and the structures now wear placeholder 3D models — a slime for a creep,
a watchtower for a tower, a crystal for the nexus — in place of the debug capsules and
boxes, handled like the hero animals: each is auto-scaled to its on-field size, stood on
the ground, and washed in its team colour, with its floating bars tucked just above its
own measured top. So the whole field reads as models rather than the heroes standing among
debug primitives. Bundled asset licenses are credited in [`CREDITS.md`](CREDITS.md).
Presentation only — the simulation and the netcode protocol are unchanged.
- The follow-camera now eases to the hero instead of locking to it 1:1, so a sharp turn or a
respawn glides the view rather than snapping it; and while the hero is gone (dead, or not yet
spawned) the camera rests where the hero last stood instead of jumping to the arena centre.
modified CREDITS.md
@@ -36,6 +36,17 @@ unchanged. All were obtained through [Poly Pizza](https://poly.pizza).
| Spider | Spider | Quaternius | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | [poly.pizza](https://poly.pizza/m/yRYJiAJyiM) |
| Chameleon | Lizard | madtrollstudio | [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) | [poly.pizza](https://poly.pizza/m/0z3NJc5zAE) |
## Field models
The lane creeps and the structures wear placeholder low-poly models too, handled the same
way — rescaled to an on-field size and washed in their team colour, otherwise unchanged.
| Prop | Model | Author | License | Source |
| :--- | :--- | :--- | :--- | :--- |
| Creep | Pink Slime | Quaternius | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | [poly.pizza](https://poly.pizza/m/AyP8sQmDLh) |
| Tower | Watch Tower | Quaternius | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | [poly.pizza](https://poly.pizza/m/VJZZW37Vsk) |
| Nexus | Crystal | iPoly3D | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | [poly.pizza](https://poly.pizza/m/SbLlgMXrQ1) |
---
[Next: Changelog →](CHANGELOG.md)
added assets/models/creeps/slime.glb
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:34bb111a7931bb957f992e127d71fac321aaeea1bdcc7da56fa5aba55c83d783
size 62652
added assets/models/creeps/slime.glb.import
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://8q5rcmdu7pqh"
path="res://.godot/imported/slime.glb-89ea079df9f75674b37c5b774ed69fd3.scn"
[deps]
source_file="res://assets/models/creeps/slime.glb"
dest_files=["res://.godot/imported/slime.glb-89ea079df9f75674b37c5b774ed69fd3.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
added assets/models/structures/nexus.glb
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:69c48ed0ee4b74a34aacb7d4fb159954dfeea79dbbba6a503bc47c50daa2e6f1
size 9760
added assets/models/structures/nexus.glb.import
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://f6ktkpf3ph17"
path="res://.godot/imported/nexus.glb-f0feb33c5b3eba5a5b2a8f4bfb124fd1.scn"
[deps]
source_file="res://assets/models/structures/nexus.glb"
dest_files=["res://.godot/imported/nexus.glb-f0feb33c5b3eba5a5b2a8f4bfb124fd1.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
added assets/models/structures/nexus_texture_1.png
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:588773b1b90bb0e133994fa1adad3901005c183a591ed6822f3d2353b92652aa
size 1856
added assets/models/structures/nexus_texture_1.png.import
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b31eos1e0c1jt"
path="res://.godot/imported/nexus_texture_1.png-f3f108f1d2fa2a9698c07ac6c3119886.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "4c7f593d2b080cf54a35bef3f88ab6b8"
}
[deps]
source_file="res://assets/models/structures/nexus_texture_1.png"
dest_files=["res://.godot/imported/nexus_texture_1.png-f3f108f1d2fa2a9698c07ac6c3119886.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
added assets/models/structures/tower.glb
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b43938378b0b38940fbf529ee78c3ae1ecae52486c06d9de1101bbb220ff71fc
size 96872
added assets/models/structures/tower.glb.import
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cg6cayh54lc0i"
path="res://.godot/imported/tower.glb-50961fd665291fff4fa0e63eaa855ea5.scn"
[deps]
source_file="res://assets/models/structures/tower.glb"
dest_files=["res://.godot/imported/tower.glb-50961fd665291fff4fa0e63eaa855ea5.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
added gdlintrc
@@ -0,0 +1,8 @@
# gdlint config — only deviation from gdtoolkit defaults below; everything else
# stays default (gdlint merges this over the built-in defaults).
#
# max-public-methods 20 -> 30: the cohesion rule targets production classes
# (largest src file holds 13 public methods), but each GUT test file accrues
# one public method per test_* case and legitimately passes 20. 30 gives the
# suite headroom without weakening src, which stays well under 20.
max-public-methods: 30
modified src/client/combat_fx.gd
@@ -21,7 +21,7 @@ const NUMBER_COLOR := Color(1.0, 0.86, 0.4)
## Ranged auto: a small bright bolt that flies from attacker to target. Flight time scales
## with the gap but is clamped so a point-blank shot still reads and a long one is not slow.
const BOLT_RADIUS := 13.0
const BOLT_SPEED := 2000.0
const BOLT_SPEED := 1300.0
const BOLT_MIN_TIME := 0.05
const BOLT_MAX_TIME := 0.22
const BOLT_COLOR := Color(1.0, 0.9, 0.55)
modified src/client/hero_model_library.gd
@@ -19,6 +19,17 @@ const HERO_MODELS := {
"chameleon": "res://assets/models/heroes/chameleon.glb",
}
## The non-hero field props that now wear models too — lane creeps and the two structure
## kinds — keyed by a prop name the presenter passes (`creep`/`tower`/`nexus`). Each entry
## carries its glTF and the on-field size its longest axis is scaled to, so a creep reads
## small under the heroes while a tower stands imposing over them. Same low-poly source
## family (Quaternius / iPoly3D, all CC0) as the hero animals, credited in CREDITS.md.
const PROP_MODELS := {
"creep": {"path": "res://assets/models/creeps/slime.glb", "size": 120.0},
"tower": {"path": "res://assets/models/structures/tower.glb", "size": 460.0},
"nexus": {"path": "res://assets/models/structures/nexus.glb", "size": 360.0},
}
## The world size a model's longest axis is scaled to, so every model reads at one size
## on the field regardless of the units it was authored in. Sized a touch above the
## capsule it replaces so the species is legible from the follow-camera.
@@ -29,6 +40,11 @@ const HERO_MODEL_SIZE := 260.0
## so an already-dark mesh (the spider) is tinted, not drowned to near-black.
const TEAM_TINT_ALPHA := 0.25
## The heavier wash a structure or creep takes — a prop has no species identity of its own
## to protect, so it leans harder into the team colour than a hero does, reading blue/red
## at a glance from across the lane.
const PROP_TINT_ALPHA := 0.4
## The yaw, in radians, that turns a kit's model to face its movement direction once the
## presenter has aimed its length axis down the move vector. The land animals are all
## authored with their body along Z but not all nose the same way, and the spider reads
@@ -126,8 +142,23 @@ static func add_to(parent: Node3D, kit_id: String, team_tint: Color) -> Node3D:
var packed := load(HERO_MODELS[kit_id]) as PackedScene
var model := packed.instantiate() as Node3D
parent.add_child(model)
_normalize(model)
_tint(model, team_tint)
_normalize(model, HERO_MODEL_SIZE)
_tint(model, team_tint, TEAM_TINT_ALPHA)
return model
## Instances a field prop's model (`prop` is a `PROP_MODELS` key — `creep`/`tower`/`nexus`)
## under `parent`, normalised to that prop's size and washed with the heavier prop tint, and
## returns it. Mirrors `add_to` for the non-hero field: a creep or a structure stands on the
## ground at a consistent size instead of a debug capsule or box. `parent` must be in the
## tree for the bounds measurement.
static func add_prop(parent: Node3D, prop: String, team_tint: Color) -> Node3D:
var def: Dictionary = PROP_MODELS[prop]
var packed := load(def["path"]) as PackedScene
var model := packed.instantiate() as Node3D
parent.add_child(model)
_normalize(model, def["size"])
_tint(model, team_tint, PROP_TINT_ALPHA)
return model
@@ -146,16 +177,16 @@ static func top_of(body: Node3D) -> float:
return top
## Scales `model` so its longest axis spans HERO_MODEL_SIZE, then offsets it so its
## footprint is centred on the parent origin and its base rests on the ground (y = 0).
## The models arrive at wildly different authored scales, so this is what makes every
## hero read at one size.
static func _normalize(model: Node3D) -> void:
## Scales `model` so its longest axis spans `size`, then offsets it so its footprint is
## centred on the parent origin and its base rests on the ground (y = 0). The models arrive
## at wildly different authored scales, so this is what makes every one read at its intended
## on-field size — a common size for the heroes, a per-prop size for creeps and structures.
static func _normalize(model: Node3D, size: float) -> void:
var aabb := _model_aabb(model)
var longest := maxf(aabb.size.x, maxf(aabb.size.y, aabb.size.z))
if longest <= 0.0:
return
var s := HERO_MODEL_SIZE / longest
var s := size / longest
model.scale = Vector3(s, s, s)
var center := aabb.get_center()
model.position = Vector3(-center.x * s, -aabb.position.y * s, -center.z * s)
@@ -178,11 +209,12 @@ static func _model_aabb(model: Node3D) -> AABB:
return out
## Lays a translucent `color` overlay over every mesh in `model`, tinting it toward its
## team without replacing the model's own material, so the species texture stays visible.
static func _tint(model: Node3D, color: Color) -> void:
## Lays a translucent `color` overlay (at opacity `alpha`) over every mesh in `model`,
## tinting it toward its team without replacing the model's own material, so the underlying
## texture stays visible. A hero takes a light wash to keep its species; a prop a heavier one.
static func _tint(model: Node3D, color: Color, alpha: float) -> void:
var wash := color
wash.a = TEAM_TINT_ALPHA
wash.a = alpha
var overlay := StandardMaterial3D.new()
overlay.albedo_color = wash
overlay.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
modified src/client/main.gd
@@ -30,8 +30,8 @@ extends Node3D
enum Mode { LOCAL, HOST, CLIENT }
const HERO_SPEED := 320.0
const BOT_SPEED := 300.0
const HERO_SPEED := 215.0
const BOT_SPEED := 200.0
const HERO_TEAM := 0
const BOT_TEAM := 1
@@ -52,7 +52,7 @@ const BOT_COLOR := Color(1.0, 0.42, 0.38)
## wave member gets, so a wave reads as a cluster apart from the heroes.
const ENTITY_RADIUS := 44.0
const HERO_BODY_HEIGHT := 150.0
const CREEP_RADIUS := 22.0
const CREEP_RADIUS := 31.0
const CREEP_BODY_HEIGHT := 80.0
const CREEP_DARKEN := 0.3
@@ -85,15 +85,14 @@ const CAM_BACK := 370.0
## the jerk out of a sharp turn or a respawn. Eyeball-tunable alongside the height/back above.
const CAM_LERP := 0.2
## Billboarded HP/resource bars + status label floating above a unit (world units). A hero's
## HP bar floats HERO_BAR_GAP above its own model's measured top (animals vary in height),
## the resource bar a step below and the status label a step above; creeps/structures fixed.
## Billboarded HP/resource bars + status label floating above a unit (world units). Every
## body's HP bar floats HERO_BAR_GAP above its own model's measured top (animals, creeps, and
## structures all vary in height), the resource bar a step below and the status label above.
const BAR_WIDTH := 170.0
const BAR_HEIGHT := 24.0
const HERO_BAR_GAP := 70.0
const RES_BAR_DROP := 36.0
const STATUS_LABEL_RISE := 70.0
const CREEP_BAR_Y := 130.0
const HP_BAR_BG := Color(0.0, 0.0, 0.0, 0.55)
const HP_BAR_FG := Color(0.4, 0.85, 0.4)
const RES_BAR_FG := Color(0.35, 0.6, 0.95)
@@ -628,6 +627,7 @@ func _build_world() -> void:
_ground.position = _world(MapData.BOUNDS.get_center())
_ground.material_override = _flat_material(GROUND_COLOR)
add_child(_ground)
MapView.build(self)
_camera = Camera3D.new()
_camera.far = 20000.0
_camera.current = true
@@ -743,15 +743,21 @@ func _make_view(entity: SimEntity) -> Dictionary:
return view
## Builds an entity's body under `root`: a size-normalised animal model for a hero whose
## kit has one (handed off to HeroModelLibrary), or a primitive (capsule unit, box
## structure) otherwise. Returned so the view can hold it, though the body is never
## mutated again once built — team and form read off the tint and the ring, not the body.
## A pure CLIENT whose snapshot carried no `kit_id` falls through to the capsule, so an
## Builds an entity's body under `root`: a size-normalised model — the hero's animal (by
## kit), a structure's tower/nexus, or a lane creep's slime — handed off to
## HeroModelLibrary, which stands it on the ground at its on-field size and washes it with
## the team colour. Returned so the view can hold it, though the body is never mutated
## again once built — team and form read off the tint and the ring, not the body. Only a
## pure CLIENT hero whose snapshot carried no `kit_id` falls through to the capsule, so an
## unmodelled hero still draws.
func _build_body(root: Node3D, entity: SimEntity) -> Node3D:
if entity.is_hero and HeroModelLibrary.has_model(entity.kit_id):
return HeroModelLibrary.add_to(root, entity.kit_id, _team_color(entity.team))
if entity.is_structure:
var prop := "nexus" if entity.is_nexus else "tower"
return HeroModelLibrary.add_prop(root, prop, _team_color(entity.team))
if entity.is_creep:
return HeroModelLibrary.add_prop(root, "creep", _team_color(entity.team).darkened(CREEP_DARKEN))
var body := MeshInstance3D.new()
body.mesh = _body_mesh(entity)
body.position = Vector3(0.0, _body_half_height(entity), 0.0)
@@ -764,7 +770,7 @@ func _build_body(root: Node3D, entity: SimEntity) -> Node3D:
## resource bar and a status label for a hero. Creeps get only a lower HP bar.
func _attach_overlay(view: Dictionary, entity: SimEntity) -> void:
var root: Node3D = view["root"]
var hp_y := _hp_bar_y(entity, view["body"])
var hp_y := _hp_bar_y(view["body"])
var hp := _make_bar(HP_BAR_FG, hp_y)
root.add_child(hp["node"])
view["hp_node"] = hp["node"]
@@ -863,15 +869,12 @@ func _body_half_height(entity: SimEntity) -> float:
return (CREEP_BODY_HEIGHT if entity.is_creep else HERO_BODY_HEIGHT) * 0.5
## The height a unit's HP bar floats at — clear above its body for each footprint. A
## hero hangs its bar a fixed gap above its own model's measured top, so the short ones
## (the chameleon) and the tall ones (the hyena) both read tucked just above the body
## rather than under one shared height tuned to no animal in particular.
func _hp_bar_y(entity: SimEntity, body: Node3D) -> float:
if entity.is_structure:
return STRUCTURE_HEIGHT + 70.0
if entity.is_creep:
return CREEP_BAR_Y
## The height a unit's HP bar floats at — a fixed gap above its own model's measured top, so
## a short body (the chameleon, a slime creep) and a tall one (the hyena, a tower) both read
## with the bar tucked just above them rather than at one shared height tuned to nothing in
## particular. Every field body is now a model, so the one measured-top rule covers heroes,
## creeps, and structures alike; only the pure-CLIENT capsule fallback has no model to measure.
func _hp_bar_y(body: Node3D) -> float:
return HeroModelLibrary.top_of(body) + HERO_BAR_GAP
added src/client/map_view.gd
@@ -0,0 +1,81 @@
class_name MapView
extends RefCounted
## The static map decor laid on the ground so the playfield reads — the lanes, the river,
## and the jungle camps — kept out of the match presenter so `main.gd` stays the driver and
## this stays the map painter.
##
## Every shape is read straight from MapData, the one geometry source the sim, the bots, and
## the tests already share, so the drawn map cannot drift from the simulated one. Pure
## presentation: flat coloured strips and discs lifted a hair above the ground to clear
## z-fighting, drawn once when the scene is built.
## The lift above the ground (y = 0) every flat decor piece sits at, so a painted strip does
## not z-fight the ground plane it lies on.
const DECOR_Y := 2.0
## Lanes: a sandy ribbon of this width tracing each lane corridor.
const LANE_WIDTH := 230.0
const LANE_COLOR := Color(0.40, 0.36, 0.25)
## River: a blue ribbon over the lanes, tracing the watercourse.
const RIVER_WIDTH := 210.0
const RIVER_COLOR := Color(0.17, 0.34, 0.52)
## Jungle camp: a flat disc marker on the ground.
const CAMP_RADIUS := 95.0
const CAMP_COLOR := Color(0.28, 0.40, 0.30)
## Paints the whole static map under `parent`: each lane as a sandy ribbon, the river as a
## blue ribbon over them, and a disc at every jungle camp. Drawn in that order so the river
## layers over the lanes. Call once, after the ground plane exists.
static func build(parent: Node3D) -> void:
for lane in MapData.lane_count():
_lay_ribbon(parent, MapData.lane_path(lane, 0), LANE_WIDTH, LANE_COLOR)
_lay_ribbon(parent, MapData.river_polyline(), RIVER_WIDTH, RIVER_COLOR)
for camp in MapData.JUNGLE_CAMPS:
_mark_disc(parent, camp, CAMP_RADIUS, CAMP_COLOR)
## Lays a flat ribbon of `width` and `color` along a polyline's segments under `parent`, each
## a thin box set on the ground at the segment midpoint and yawed to its heading — so a lane
## or the river reads as a continuous painted strip rather than a row of disconnected marks.
static func _lay_ribbon(
parent: Node3D, points: PackedVector2Array, width: float, color: Color
) -> void:
var material := _flat_material(color)
for i in points.size() - 1:
var a := points[i]
var b := points[i + 1]
var delta := b - a
var length := delta.length()
if length <= 0.0:
continue
var strip := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(width, 1.0, length)
strip.mesh = box
strip.material_override = material
var mid := (a + b) * 0.5
strip.position = Vector3(mid.x, DECOR_Y, mid.y)
strip.rotation.y = atan2(delta.x, delta.y)
parent.add_child(strip)
## Marks a flat disc of `radius` and `color` on the ground at a field point — a jungle camp.
static func _mark_disc(parent: Node3D, pos: Vector2, radius: float, color: Color) -> void:
var disc := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = radius
cyl.bottom_radius = radius
cyl.height = 1.0
disc.mesh = cyl
disc.material_override = _flat_material(color)
disc.position = Vector3(pos.x, DECOR_Y, pos.y)
parent.add_child(disc)
static func _flat_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
return mat
added src/client/map_view.gd.uid
@@ -0,0 +1 @@
uid://cou063i1hprmj
modified src/sim/map_data.gd
@@ -2,80 +2,135 @@ class_name MapData
extends RefCounted
## Static geometry for the 3v3 arena.
##
## The map is point-symmetric through the origin: team 1's geometry is team 0's
## negated, so every structure has a mirrored counterpart and neither side has a
## positional edge. The simulation reads this layer as pure data — bounds, the
## team bases, the lane corridors, and the neutral jungle camps — with no engine
## or render coupling, so bots, tests, and (later) the netcode share one source
## of truth.
## The map is axially symmetric across the TL–BR diagonal — the line y = x: team 1's
## geometry is team 0's reflected across that axis, (x, y) → (y, x), which swaps the two
## bases so neither side has a positional edge. The simulation reads this layer as pure
## data — bounds, the team bases, the lane corridors, the river, and the neutral jungle
## camps — with no engine or render coupling, so bots, tests, and (later) the netcode share
## one source of truth.
## Playable area, in world units, centred on the origin.
const BOUNDS := Rect2(-2000.0, -2000.0, 4000.0, 4000.0)
## Playable area, in world units, centred on the origin. Sized to about 65% of a 5v5 map's
## side length — a tighter arena for 3v3.
const BOUNDS := Rect2(-4800.0, -4800.0, 9600.0, 9600.0)
## The nexus position for each team, indexed by team id. The nexus is the win
## condition (destroyed to end the match) and the inner anchor both lane
## corridors converge on at a base. Point-symmetric: index 1 is index 0 negated.
## corridors converge on at a base. Axially symmetric: index 1 is index 0 mirrored
## across the y = x axis.
const NEXUS_POSITIONS: Array[Vector2] = [
Vector2(-1600.0, 1600.0),
Vector2(1600.0, -1600.0),
Vector2(-3840.0, 3840.0),
Vector2(3840.0, -3840.0),
]
## How far in front of the nexus a team's heroes spawn — a fountain pulled toward
## the map centre so a hero starts at its base without sitting on the nexus.
const FOUNTAIN_PULLBACK := 300.0
const FOUNTAIN_PULLBACK := 720.0
## Lateral gap between squadmates fanned across a base fountain, so a full team
## spawns side by side instead of stacked on one point.
const SQUAD_SPACING := 150.0
## Top corridor: out of team 0's base, up the left edge, across the top.
## Top corridor: out of team 0's base, bulging up the left side, into team 1's base. It is its
## own reflection across the y = x axis (its midpoint sits on the axis), so it is team-fair.
const LANE_TOP: Array[Vector2] = [
Vector2(-1600.0, 1600.0),
Vector2(-1600.0, -1600.0),
Vector2(1600.0, -1600.0),
Vector2(-3840.0, 3840.0),
Vector2(-2640.0, -240.0),
Vector2(-2160.0, -1440.0),
Vector2(-1440.0, -2160.0),
Vector2(-240.0, -2640.0),
Vector2(3840.0, -3840.0),
]
## Bottom corridor: out of team 0's base, across the bottom, up the right edge.
## Bottom corridor: out of team 0's base, bulging down the right side, into team 1's base. Like
## the top corridor it is its own reflection across the y = x axis, so the two teams meet it
## the same way.
const LANE_BOTTOM: Array[Vector2] = [
Vector2(-1600.0, 1600.0),
Vector2(1600.0, 1600.0),
Vector2(1600.0, -1600.0),
Vector2(-3840.0, 3840.0),
Vector2(1200.0, 3720.0),
Vector2(2280.0, 3480.0),
Vector2(3000.0, 3000.0),
Vector2(3480.0, 2280.0),
Vector2(3720.0, 1200.0),
Vector2(3840.0, -3840.0),
]
## The lane corridors, each a polyline stored from team 0's nexus to team 1's
## nexus. Both teams push both corridors in opposite directions; `lane_path`
## orients a corridor for a given team's travel. The two corridors are point
## reflections of each other (negate-and-reverse maps one onto the other), so the
## map stays mirror-fair. v0.1 is two lanes plus the jungle — there is no mid.
## The lane corridors, each a polyline stored from team 0's nexus to team 1's nexus. Both teams
## push both corridors in opposite directions; `lane_path` orients a corridor for a given team's
## travel. Each corridor is its own reflection across the y = x axis, so walking it forward
## (team 0) and reversed (team 1) are mirror experiences. v0.1 is two lanes plus the jungle —
## there is no mid.
const LANES: Array[Array] = [LANE_TOP, LANE_BOTTOM]
## Neutral jungle camp positions in the open band between the lanes. Closed under
## negation (every camp has a mirrored partner; the centre camp is its own), so
## the neutral layer is symmetric like the rest of the map.
## Neutral jungle camp positions in the open ground between the lanes. Closed under the axis
## mirror (every off-axis camp has a partner across y = x; a camp on the axis is its own), so the
## neutral layer stays team-fair: each team reaches the same camps the same way.
const JUNGLE_CAMPS: Array[Vector2] = [
Vector2(0.0, 0.0),
Vector2(1080.0, 1080.0),
Vector2(150.0, 2450.0),
Vector2(-3360.0, -3360.0),
Vector2(2450.0, 150.0),
Vector2(1550.0, -1700.0),
Vector2(-1700.0, 1550.0),
]
## The river: a single watercourse that meanders across the middle of the map, its apex sitting
## on the y = x axis. Stored as a polyline; reflecting it across the axis and reversing maps it
## onto itself, so it divides the map evenly and neither team has more water in its half. Any
## barrier rule that makes the banks block movement is a later step; for now this is just the
## geometry the map is drawn from.
const RIVER: Array[Vector2] = [
Vector2(720.0, -4800.0),
Vector2(-200.0, -3900.0),
Vector2(-550.0, -3550.0),
Vector2(-750.0, -3200.0),
Vector2(-850.0, -2800.0),
Vector2(-800.0, -2400.0),
Vector2(-550.0, -2100.0),
Vector2(-300.0, -1800.0),
Vector2(-200.0, -1500.0),
Vector2(-200.0, -1100.0),
Vector2(-300.0, -750.0),
Vector2(-500.0, -500.0),
Vector2(500.0, 500.0),
Vector2(500.0, -500.0),
Vector2(-500.0, 500.0),
Vector2(-750.0, -300.0),
Vector2(-1100.0, -200.0),
Vector2(-1500.0, -200.0),
Vector2(-1800.0, -300.0),
Vector2(-2100.0, -550.0),
Vector2(-2400.0, -800.0),
Vector2(-2800.0, -850.0),
Vector2(-3200.0, -750.0),
Vector2(-3550.0, -550.0),
Vector2(-3900.0, -200.0),
Vector2(-4800.0, 720.0),
]
## Defensive tower slots for team 0: two per lane, sitting on the lane segment
## that leaves team 0's base, so each corridor is guarded on the way in. Team 1's
## slots are these negated (see `tower_positions`), which — because the lane set
## is closed under point reflection — also land on the lanes, by team 1's base.
## Every slot lies exactly on a lane polyline segment and inside the bounds.
const TOWER_SLOTS_TEAM0: Array[Vector2] = [
Vector2(-1600.0, 800.0),
Vector2(-1600.0, -800.0),
Vector2(-800.0, 1600.0),
Vector2(800.0, 1600.0),
## Every defensive tower slot on the map, as one axially-symmetric set (each off-axis slot has a
## partner across y = x). Per team: two towers ringing the nexus and two forward towers down the
## lanes — four towers a side. `tower_positions` hands a team the slots on its own side of the
## axis, so the two teams field mirror-image defences without either holding an extra tower.
## (The two forward slots are where a future "ford" river-crossing could replace a tower — that
## idea is deferred; for now they are plain towers.)
const TOWER_SLOTS: Array[Vector2] = [
Vector2(2520.0, -3480.0),
Vector2(-3480.0, 2520.0),
Vector2(-2520.0, 3840.0),
Vector2(3840.0, -2520.0),
Vector2(-2640.0, -240.0),
Vector2(-240.0, -2640.0),
Vector2(840.0, 3720.0),
Vector2(3720.0, 840.0),
]
## Reflection across the diagonal axis y = x — the map's mirror. An involution (its own inverse)
## that swaps the two bases, so team 1's geometry is team 0's mirrored.
static func mirror(p: Vector2) -> Vector2:
return Vector2(p.y, p.x)
## A team's hero spawn: its base fountain, set just in front of the nexus toward
## the map centre. Derived from `NEXUS_POSITIONS`, so the two teams' spawns mirror
## through the origin like the rest of the map.
## across the y = x axis like the rest of the map.
static func spawn_for_team(team: int) -> Vector2:
var nexus := nexus_for_team(team)
return nexus - nexus.normalized() * FOUNTAIN_PULLBACK
@@ -85,31 +140,42 @@ static func nexus_for_team(team: int) -> Vector2:
return NEXUS_POSITIONS[team % NEXUS_POSITIONS.size()]
## A squadmate's spawn within its team's roster of `count`, fanned laterally
## across the base fountain so the team starts side by side rather than stacked.
## `index` runs 0..count-1; the fan is centred on the fountain and laid out along
## the axis perpendicular to the base→centre direction. Mirror-fair like the rest
## of the map: team 1's squad spawns are team 0's negated, because the fountain,
## the inward direction, and the lateral axis all negate between teams.
## A squadmate's spawn within its team's roster of `count`, fanned laterally across the base
## fountain so the team starts side by side rather than stacked. `index` runs 0..count-1; the fan
## is centred on the fountain and laid out along the axis perpendicular to the base→centre
## direction. Team 1's seats are team 0's mirrored across y = x, so neither side has an edge.
static func squad_spawn(team: int, index: int, count: int) -> Vector2:
var fountain := spawn_for_team(team)
var seat := _squad_seat_team0(index, count)
return seat if team % 2 == 0 else mirror(seat)
## Team 0's squad seat for `index` of `count`, the bare geometry the mirror is taken from.
static func _squad_seat_team0(index: int, count: int) -> Vector2:
var fountain := spawn_for_team(0)
if count <= 1:
return fountain
var inward := -nexus_for_team(team).normalized() # base toward the map centre
var inward := -nexus_for_team(0).normalized() # base toward the map centre
var lateral := Vector2(-inward.y, inward.x) # perpendicular to the inward axis
var offset := float(index) - float(count - 1) * 0.5
return clamp_to_bounds(fountain + lateral * (offset * SQUAD_SPACING))
## The tower slots for `team`: team 0's stored slots, negated for team 1 so the
## two teams' defences are point reflections of each other. Returns a fresh copy
## so callers cannot mutate the stored geometry.
## The tower slots for `team`: the stored slots on team 0's side of the y = x axis, handed back
## as-is for team 0 and mirrored for team 1, so the two teams' defences are reflections of each
## other. Returns a fresh copy so callers cannot mutate the stored geometry.
static func tower_positions(team: int) -> PackedVector2Array:
var slots := PackedVector2Array(TOWER_SLOTS_TEAM0)
if team % 2 == 1:
for i in slots.size():
slots[i] = -slots[i]
return slots
return _team_side(TOWER_SLOTS, team)
## The members of an axially-symmetric point set that fall on team 0's side of the y = x axis
## (where y > x), handed back as-is for team 0 and mirrored for team 1. A point on the axis
## itself belongs to neither side and is dropped — these are per-team defences, not neutral.
static func _team_side(points: Array[Vector2], team: int) -> PackedVector2Array:
var out := PackedVector2Array()
for p in points:
if p.y > p.x: # team 0's side of the diagonal
out.append(p if team % 2 == 0 else mirror(p))
return out
static func lane_count() -> int:
@@ -126,6 +192,11 @@ static func lane_path(lane: int, team: int) -> PackedVector2Array:
return path
## The river polyline, as a fresh copy so callers cannot mutate the stored course.
static func river_polyline() -> PackedVector2Array:
return PackedVector2Array(RIVER)
static func clamp_to_bounds(pos: Vector2) -> Vector2:
return Vector2(
clampf(pos.x, BOUNDS.position.x, BOUNDS.end.x),
modified src/sim/sim_core.gd
@@ -27,7 +27,7 @@ const CREEP_HP := 100
const CREEP_DAMAGE := 20
const CREEP_RANGE := 150.0
const CREEP_COOLDOWN_TICKS := 30
const CREEP_SPEED := 180.0
const CREEP_SPEED := 210.0
## Wave cadence. Both teams spawn a wave per lane every interval, the first at
## tick 0. Creeps within a wave are strung along the lane so they file out of the
@@ -95,9 +95,9 @@ func add_structure(
return _register(entity)
## Populates the arena's structures from the map: each team's lane towers plus
## its destructible nexus. Both teams' structures mirror through the origin, so
## the match starts mirror-fair.
## Populates the arena's structures from the map — each team's four towers (two ringing the
## nexus, two forward down the lanes) plus its destructible nexus. Both teams' structures mirror
## across the map's y = x axis, so the match starts mirror-fair.
func spawn_structures() -> void:
for team in MapData.NEXUS_POSITIONS.size():
for slot in MapData.tower_positions(team):
@@ -166,9 +166,9 @@ func add_creep(team: int, lane: int, position: Vector2) -> int:
return _register(entity)
## Advances the world by exactly one tick: spawn waves, move the input-driven
## units, march the creeps, resolve combat, then deaths. `inputs` maps an entity
## id to its InputCommand; an entity with no command holds still. Pure: the
## Advances the world by exactly one tick: spawn waves, revive the dead, move the
## input-driven units, march the creeps, resolve combat, then deaths. `inputs` maps an
## entity id to its InputCommand; an entity with no command holds still. Pure: the
## result is a function of the prior state and `inputs` only (creep waves spawn
## off `state.tick`). Once a nexus has fallen the match is over and step no-ops.
func step(inputs: Dictionary) -> void:
@@ -233,7 +233,8 @@ func _step_spawning() -> void:
## Spawns `CREEP_PER_WAVE` creeps for `team` on `lane`, strung forward along the
## first lane segment so they file out of the base instead of stacking. Because
## the lanes are point-symmetric, the two teams' waves mirror through the origin.
## each lane is its own reflection across the y = x axis, the two teams' waves
## mirror across that axis.
func _spawn_wave(team: int, lane: int) -> void:
var path := MapData.lane_path(lane, team)
var origin := path[0]
modified test/unit/test_map_data.gd
@@ -1,21 +1,27 @@
extends GutTest
## Geometry contracts for the 3v3 arena. These pin the map's mirror-fairness and
## the lane orientation the creep layer will depend on. Pure data checks — no
## engine or render coupling, the same discipline as the sim-core tests.
## Geometry contracts for the 3v3 arena. These pin the map's mirror-fairness across the y = x
## axis and the lane orientation the creep layer depends on. Pure data checks — no engine or
## render coupling, the same discipline as the sim-core tests.
func test_nexuses_are_point_symmetric_and_in_bounds() -> void:
func test_mirror_swaps_components_and_is_its_own_inverse() -> void:
var p := Vector2(120.0, -340.0)
assert_eq(MapData.mirror(p), Vector2(-340.0, 120.0), "the mirror reflects across y = x")
assert_eq(MapData.mirror(MapData.mirror(p)), p, "mirroring twice returns the original point")
func test_nexuses_are_axially_symmetric_and_in_bounds() -> void:
var n0 := MapData.nexus_for_team(0)
var n1 := MapData.nexus_for_team(1)
assert_eq(n1, -n0, "team 1's nexus must be team 0's nexus negated")
assert_eq(n1, MapData.mirror(n0), "team 1's nexus must be team 0's nexus mirrored across y = x")
assert_eq(MapData.clamp_to_bounds(n0), n0, "nexus 0 must sit inside the bounds")
assert_eq(MapData.clamp_to_bounds(n1), n1, "nexus 1 must sit inside the bounds")
func test_team_fountains_sit_at_base_and_mirror_through_the_origin() -> void:
func test_team_fountains_sit_at_base_and_mirror_across_the_axis() -> void:
var f0 := MapData.spawn_for_team(0)
var f1 := MapData.spawn_for_team(1)
assert_eq(f1, -f0, "team 1's fountain must be team 0's fountain negated")
assert_eq(f1, MapData.mirror(f0), "team 1's fountain must be team 0's fountain mirrored")
assert_eq(MapData.clamp_to_bounds(f0), f0, "the fountain must sit inside the bounds")
# It is at base: closer to its own nexus than to the map centre.
var nexus := MapData.nexus_for_team(0)
@@ -50,37 +56,46 @@ func test_lane_path_returns_a_fresh_copy() -> void:
assert_ne(first, second, "mutating a returned path must not alter the stored geometry")
func test_lanes_are_point_reflections_of_each_other() -> void:
# Negate-and-reverse must map the set of corridors onto itself, so the two
# lanes are mirror images and neither team has a shorter or safer route.
var canon: Array[PackedVector2Array] = []
func test_each_lane_is_its_own_axial_reflection() -> void:
# Mirror-and-reverse must map each corridor onto itself, so the two teams walk a lane the
# same way and neither has a shorter or safer route.
for lane in MapData.lane_count():
canon.append(MapData.lane_path(lane, 0))
for path in canon:
var path := MapData.lane_path(lane, 0)
var mirrored := PackedVector2Array()
for i in range(path.size() - 1, -1, -1):
mirrored.append(-path[i])
var found := false
for other in canon:
if other == mirrored:
found = true
break
assert_true(found, "every lane's point reflection must also be a lane")
mirrored.append(MapData.mirror(path[i]))
assert_eq(mirrored, path, "each lane must be its own reflection across the y = x axis")
func test_jungle_camps_are_closed_under_negation_and_in_bounds() -> void:
func test_jungle_camps_are_closed_under_the_axis_mirror_and_in_bounds() -> void:
var camps := MapData.JUNGLE_CAMPS
for camp in camps:
assert_eq(MapData.clamp_to_bounds(camp), camp, "every camp must sit inside the bounds")
assert_true(camps.has(-camp), "every camp must have a mirrored partner (negated)")
assert_true(camps.has(MapData.mirror(camp)), "every camp must have a mirror partner across y = x")
func test_two_jungle_camps_are_neutral_on_the_axis() -> void:
# The camps sitting on the y = x axis are their own mirror — the shared, team-neutral camps.
# The map has exactly two; the rest are off-axis mirror pairs, one per side.
var neutral := 0
for camp in MapData.JUNGLE_CAMPS:
if is_equal_approx(camp.x, camp.y):
neutral += 1
assert_eq(neutral, 2, "exactly two jungle camps are neutral (on the y = x axis)")
func test_tower_positions_are_point_symmetric_between_teams() -> void:
func test_tower_positions_are_axially_symmetric_between_teams() -> void:
var team0 := MapData.tower_positions(0)
var team1 := MapData.tower_positions(1)
assert_eq(team0.size(), team1.size(), "both teams field the same number of towers")
for i in team0.size():
assert_eq(team1[i], -team0[i], "team 1's towers must be team 0's towers negated")
assert_eq(team1[i], MapData.mirror(team0[i]), "team 1's towers must be team 0's mirrored")
func test_tower_positions_are_in_bounds() -> void:
for team in MapData.NEXUS_POSITIONS.size():
for tower in MapData.tower_positions(team):
assert_eq(MapData.clamp_to_bounds(tower), tower, "every tower must sit inside the bounds")
func test_tower_positions_returns_a_fresh_copy() -> void:
@@ -90,18 +105,6 @@ func test_tower_positions_returns_a_fresh_copy() -> void:
assert_ne(first[0], second[0], "mutating a returned tower list must not alter the stored slots")
func test_every_tower_sits_on_a_lane_and_inside_the_bounds() -> void:
for team in MapData.NEXUS_POSITIONS.size():
for tower in MapData.tower_positions(team):
assert_eq(MapData.clamp_to_bounds(tower), tower, "every tower must sit inside the bounds")
var on_a_lane := false
for lane in MapData.lane_count():
if _point_on_polyline(tower, MapData.lane_path(lane, 0)):
on_a_lane = true
break
assert_true(on_a_lane, "every tower must sit on a lane corridor")
func test_squad_spawn_of_one_falls_back_to_the_fountain() -> void:
for team in MapData.NEXUS_POSITIONS.size():
assert_eq(
@@ -122,13 +125,13 @@ func test_squad_spawn_fans_a_team_into_distinct_in_bounds_seats() -> void:
seen.append(seat)
func test_squad_spawn_is_point_symmetric_between_teams() -> void:
func test_squad_spawn_is_axially_symmetric_between_teams() -> void:
var count := 3
for i in count:
assert_eq(
MapData.squad_spawn(1, i, count),
-MapData.squad_spawn(0, i, count),
"team 1's squad seat must be team 0's negated, so neither side has an edge",
MapData.mirror(MapData.squad_spawn(0, i, count)),
"team 1's squad seat must be team 0's mirrored, so neither side has an edge",
)
@@ -144,17 +147,26 @@ func test_squad_spawn_fan_is_centred_on_the_fountain() -> void:
)
## True when `point` lies on one of the polyline's segments (within a small
## tolerance): the segment endpoints span it and it is collinear with them.
func _point_on_polyline(point: Vector2, path: PackedVector2Array) -> bool:
for i in path.size() - 1:
var a := path[i]
var b := path[i + 1]
var ab := b - a
var ap := point - a
if absf(ab.cross(ap)) > 0.01:
continue
var t := ap.dot(ab) / ab.length_squared()
if t >= 0.0 and t <= 1.0:
return true
return false
func test_river_is_axially_symmetric_and_in_bounds() -> void:
# Mirror-and-reverse must map the river onto itself, so it divides the map evenly and
# neither team has more water in its half.
var river := MapData.river_polyline()
var mirrored := PackedVector2Array()
for i in range(river.size() - 1, -1, -1):
mirrored.append(MapData.mirror(river[i]))
assert_eq(mirrored, river, "the river must be its own reflection across the y = x axis")
for point in river:
assert_eq(MapData.clamp_to_bounds(point), point, "every river point sits inside the bounds")
func test_river_polyline_returns_a_fresh_copy() -> void:
var first := MapData.river_polyline()
first.reverse()
var second := MapData.river_polyline()
assert_ne(first, second, "mutating the returned river must not alter the stored course")
func test_each_team_holds_four_towers() -> void:
# Two towers ring the nexus and two stand forward down the lanes — four a side, mirrored.
assert_eq(MapData.tower_positions(0).size(), 4, "each team fields four towers")
assert_eq(MapData.tower_positions(1).size(), 4, "both teams field the same count")
modified test/unit/test_net_protocol.gd
@@ -43,7 +43,8 @@ func test_a_full_snapshot_fits_in_one_unreliable_datagram() -> void:
# it must fit one datagram so the snapshot is not fragmented above the transport
# MTU (~1392 bytes) — the regression guard for the binary wire format.
var state := _opening_wave_state()
assert_gt(state.entities.size(), 20, "the opening wave is a heavy world")
# Proves the full creep wave plus the structures spawned — the heaviest realistic world.
assert_gt(state.entities.size(), 15, "the opening wave is a heavy world")
var bytes := NetProtocol.encode_snapshot(state)
assert_lt(bytes.size(), 1392, "the packed snapshot fits one datagram, below the MTU")
modified test/unit/test_sim_core.gd
@@ -183,19 +183,39 @@ func test_nexus_destruction_sets_the_winner_and_freezes_the_match() -> void:
func test_spawn_structures_is_mirror_fair() -> void:
var sim := SimCore.new()
sim.spawn_structures()
# Every team 0 structure must have a team 1 structure at the negated position
# Every team 0 structure must have a team 1 structure at the axially mirrored position
# with the same role and health, so neither side starts ahead.
for id in sim.state.entities:
var s: SimEntity = sim.state.entities[id]
if s.team != 0:
continue
var mirror := _structure_at(sim.state, 1, -s.position)
var mirror := _structure_at(sim.state, 1, MapData.mirror(s.position))
assert_not_null(mirror, "team 0's structure must have a mirrored team 1 counterpart")
if mirror != null:
assert_eq(mirror.is_nexus, s.is_nexus, "the mirrored structure must share its role")
assert_eq(mirror.max_hp, s.max_hp, "the mirrored structure must share its health")
func test_spawn_structures_gives_each_team_a_nexus_and_four_towers() -> void:
# A team's defences: one destructible nexus plus four towers — two ringing the nexus and
# two forward down the lanes.
var sim := SimCore.new()
sim.spawn_structures()
for team in MapData.NEXUS_POSITIONS.size():
var nexuses := 0
var towers := 0
for id in sim.state.entities:
var s: SimEntity = sim.state.entities[id]
if not s.is_structure or s.team != team:
continue
if s.is_nexus:
nexuses += 1
else:
towers += 1
assert_eq(nexuses, 1, "a team has exactly one nexus")
assert_eq(towers, 4, "a team fields four towers — two guarding the nexus, two forward")
func _structure_at(state: SimState, team: int, position: Vector2) -> SimEntity:
for id in state.entities:
var s: SimEntity = state.entities[id]
@@ -316,8 +336,8 @@ func test_creep_waves_are_mirror_fair() -> void:
if not creep.is_creep or creep.team != 0:
continue
assert_not_null(
_creep_at(sim.state, 1, -creep.position),
"every team-0 creep has a team-1 creep mirrored through the origin",
_creep_at(sim.state, 1, MapData.mirror(creep.position)),
"every team-0 creep has a team-1 creep mirrored across the y = x axis",
)
added tools/map_editor.gd
@@ -0,0 +1,685 @@
extends SceneTree
## Live top-down map editor: place, drag, and delete the map geometry by clicking, build it
## symmetric across the TL-BR diagonal with one click per side, then write the result straight
## back into src/sim/map_data.gd. Built so the map can be designed by eye instead of by typing
## coordinates — the companion to tools/map_shot.gd (which is a still snapshot; this is live).
##
## Run it (window stays open, it is interactive):
## godot --path . -s tools/map_editor.gd
##
## Controls
## 1..6 pick the layer: 1 River 2 Camps 3 Nexus 4 Towers 5 LaneT 6 LaneB
## left-click empty ground -> add a point to the active layer (Nexus: drag only)
## on a point -> grab it; hold and move to drag
## on a river segment -> insert a vertex there, splitting it
## right-click on a point -> delete it (Nexus: ignored — always two bases)
## S symmetry-lock: edits mirror across the y=x axis automatically (default ON)
## A snap the hovered point exactly onto the y=x axis
## Z undo the last edit
## G grid snap (50) H mirror ghost (shown when symmetry-lock is off)
## wheel zoom middle-drag pan R reset the view
## W WRITE the layers back into map_data.gd (a .bak is saved first)
## ESC quit
##
## The map mirrors AXIALLY across the TL-BR diagonal (the bright line, sim y = x): a point
## (x, y) mirrors to (y, x), which swaps the two bases, so the map stays team-fair. With
## symmetry-lock on, every add/drag/delete keeps a point and its mirror partner in step (river
## vertices pair by order, end-to-end; camps/towers pair by position; an on-axis point is
## its own mirror). The two lanes (LaneT, LaneB) are polylines like the river; each is its own
## mirror across the axis. Lane endpoints are not pinned to the nexus — line them up by eye.
const VIEW := 10560.0 # default world units the orthographic camera frames (past the 9600 bounds)
const SNAP := 50.0 # grid step a placed/dragged point snaps to when snap is on
const GRAB := 312.0 # world-unit radius within which a click grabs an existing point
const INSERT_DIST := 288.0 # how close to a river segment a click must be to insert a vertex
const PAIR_EPS := 192.0 # how close a point must sit to another's mirror to count as its partner
const AXIS_EPS := 2.0 # |x - y| under which a point counts as sitting on the y=x axis
const ZOOM_MIN := 2160.0
const ZOOM_MAX := 17280.0
const ZOOM_STEP := 1.12
const UNDO_MAX := 120
const MAP_PATH := "res://src/sim/map_data.gd"
# The editable layers, in number-key order. `add` is false for layers with a fixed membership
# (the two bases) — those points can be dragged but not created or deleted. `const_name` is the
# MapData const the layer writes back to.
# `poly` layers are ordered polylines (river, lanes): drawn as a connected line, numbered, with
# click-on-segment insert, and — under symmetry-lock — self-mirrored end-to-end. The rest are
# point sets that pair by position. `add` is false for fixed-membership layers (the two bases).
const LAYERS := [
{"key": "river", "label": "River", "const_name": "RIVER", "add": true, "poly": true,
"color": Color(0.30, 0.55, 0.85)},
{"key": "camps", "label": "Camps", "const_name": "JUNGLE_CAMPS", "add": true,
"color": Color(0.40, 0.70, 0.45)},
{"key": "nexus", "label": "Nexus", "const_name": "NEXUS_POSITIONS", "add": false,
"color": Color(0.85, 0.50, 0.85)},
{"key": "towers", "label": "Towers", "const_name": "TOWER_SLOTS", "add": true,
"color": Color(0.80, 0.40, 0.40)},
{"key": "lane_t", "label": "LaneT", "const_name": "LANE_TOP", "add": true, "poly": true,
"color": Color(0.72, 0.62, 0.40)},
{"key": "lane_b", "label": "LaneB", "const_name": "LANE_BOTTOM", "add": true, "poly": true,
"color": Color(0.55, 0.62, 0.42)},
]
var _cam: Camera3D
var _world: Node3D
var _decor: Node3D # everything redrawn each frame lives under here
var _hud: RichTextLabel
var _model := {} # layer key -> Array[Vector2], the live geometry being edited
var _layer := 0 # index into LAYERS of the active layer
var _drag := -1 # index of the point being dragged in the active layer, or -1
var _snap_on := true
var _ghost_on := true
var _sym_on := true # symmetry-lock: keep each point and its axial mirror in step
var _undo: Array = [] # stack of whole-model snapshots for Z
var _prev_keys := {}
var _was_left := false
var _was_right := false
var _was_mid := false
var _last_px := Vector2.ZERO
func _initialize() -> void:
DisplayServer.window_set_size(Vector2i(1000, 1000))
DisplayServer.window_set_title("Theria — map editor")
_world = Node3D.new()
get_root().add_child(_world)
# Overhead orthographic camera: sim x reads as screen-x, sim y as screen-down, matching a
# top-down map on paper and tools/map_shot.gd.
_cam = Camera3D.new()
_cam.projection = Camera3D.PROJECTION_ORTHOGONAL
_cam.size = VIEW
_cam.position = Vector3(0.0, 6000.0, 0.0)
_cam.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
_cam.far = 20000.0
_world.add_child(_cam)
_cam.make_current()
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
_world.add_child(light)
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = MapData.BOUNDS.size
ground.mesh = plane
ground.material_override = _mat(Color(0.12, 0.13, 0.15))
_world.add_child(ground)
_decor = Node3D.new()
_world.add_child(_decor)
var layer2d := CanvasLayer.new()
get_root().add_child(layer2d)
_hud = RichTextLabel.new()
_hud.bbcode_enabled = true
_hud.scroll_active = false
_hud.autowrap_mode = TextServer.AUTOWRAP_OFF # keep the layer tabs on one line
_hud.mouse_filter = Control.MOUSE_FILTER_IGNORE # never eat clicks/wheel under the HUD
_hud.position = Vector2(12.0, 8.0)
_hud.size = Vector2(980.0, 140.0)
_hud.add_theme_font_size_override("normal_font_size", 16)
_hud.add_theme_font_size_override("bold_font_size", 16)
layer2d.add_child(_hud)
# A tiny node that catches discrete mouse-wheel events for zoom — the SceneTree script
# itself does not receive input events, so this forwards them back to us.
var wheel := WheelCatcher.new()
wheel.editor = self
get_root().add_child(wheel)
_load_model()
print("map editor — 1..5 layer | click add/drag/insert | right delete | S sym | Z undo | W write")
## Copies the MapData consts into the mutable in-memory model so editing never touches the
## stored geometry until W writes it back.
func _load_model() -> void:
_model["river"] = _copy(MapData.RIVER)
_model["camps"] = _copy(MapData.JUNGLE_CAMPS)
_model["nexus"] = _copy(MapData.NEXUS_POSITIONS)
_model["towers"] = _copy(MapData.TOWER_SLOTS)
_model["lane_t"] = _copy(MapData.LANE_TOP)
_model["lane_b"] = _copy(MapData.LANE_BOTTOM)
## True if `key` is an ordered polyline (river or a lane): drawn connected, numbered, with
## click-on-segment insert and end-to-end self-mirroring.
func _poly(key: String) -> bool:
for layer in LAYERS:
if layer["key"] == key:
return layer.get("poly", false)
return false
## True if `key`'s points pair by order rather than by position — the polylines (self-mirror,
## end to end) and the nexus pair (the two bases).
func _ordered(key: String) -> bool:
return key == "nexus" or _poly(key)
func _copy(points: Array) -> Array:
var out := []
for p in points:
out.append(p)
return out
func _process(_delta: float) -> bool:
_handle_keys()
_handle_mouse()
_handle_camera()
_redraw()
_update_hud()
return false # keep the editor running; the window close / ESC quits it
# --- input -------------------------------------------------------------------------------------
func _handle_keys() -> void:
for i in LAYERS.size():
if _tap(KEY_1 + i):
_layer = i
_drag = -1
if _tap(KEY_G):
_snap_on = not _snap_on
if _tap(KEY_H):
_ghost_on = not _ghost_on
if _tap(KEY_S):
_sym_on = not _sym_on
if _tap(KEY_A):
_axis_snap()
if _tap(KEY_Z):
_undo_last()
if _tap(KEY_R):
_cam.size = VIEW
_cam.position = Vector3(0.0, 6000.0, 0.0)
if _tap(KEY_W):
_write_back()
if _tap(KEY_ESCAPE):
quit()
func _handle_mouse() -> void:
var world := _cursor_world()
var points: Array = _active_points()
var key: String = _active_layer()["key"]
var left := Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT)
if left and not _was_left:
var hit := _nearest(points, world)
if hit >= 0:
_push_undo()
_drag = hit
elif _poly(key):
_push_undo()
var seg := _nearest_segment(points, world)
if seg >= 0:
_drag = _river_insert(points, seg, _place(world))
else:
_drag = _add(points, key, _place(world))
elif _active_layer()["add"]:
_push_undo()
_drag = _add(points, key, _place(world))
elif left and _drag >= 0:
var newpos := _place(world)
var partner := _partner(points, _drag, key) if _sym_on else -1
points[_drag] = newpos
if _sym_on and partner >= 0 and partner != _drag and not _on_axis(newpos):
points[partner] = _mirror(newpos)
elif not left:
_drag = -1
_was_left = left
var right := Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
if right and not _was_right and _active_layer()["add"]:
var hit := _nearest(points, world)
if hit >= 0:
_push_undo()
_delete(points, key, hit)
_drag = -1
_was_right = right
## Mouse-wheel zoom and middle-button drag-to-pan. Both leave the camera looking straight down,
## so the cursor-to-world mapping stays correct.
func _handle_camera() -> void:
var mid := Input.is_mouse_button_pressed(MOUSE_BUTTON_MIDDLE)
var px := get_root().get_mouse_position()
if mid and _was_mid:
var per_px := _cam.size / float(get_root().size.y)
var d := px - _last_px
_cam.position.x -= d.x * per_px
_cam.position.z -= d.y * per_px
_last_px = px
_was_mid = mid
func _zoom(dir: int) -> void:
var f := ZOOM_STEP if dir > 0 else 1.0 / ZOOM_STEP
_cam.size = clampf(_cam.size * f, ZOOM_MIN, ZOOM_MAX)
# --- editing model -----------------------------------------------------------------------------
## Adds a point to a layer. With symmetry-lock on, an off-axis point gets its mirror partner too:
## a river point is mirrored at the far end of the polyline (keeping the end-to-end pairing), a
## point-set point gets a mirrored twin. Returns the index of the user's own point.
func _add(points: Array, key: String, p: Vector2) -> int:
if _poly(key):
points.append(p)
if _sym_on and not _on_axis(p):
points.insert(0, _mirror(p)) # mirror at the opposite end keeps i <-> last-i
return points.size() - 1
points.append(p)
var idx := points.size() - 1
if _sym_on and not _on_axis(p):
points.append(_mirror(p))
return idx
## Inserts a vertex into the river polyline on segment `seg` (between seg and seg+1). With
## symmetry-lock on, the mirror vertex is inserted on the mirrored segment so the end-to-end
## pairing survives. Returns the index of the user's own new vertex.
func _river_insert(points: Array, seg: int, p: Vector2) -> int:
if not _sym_on or _on_axis(p):
points.insert(seg + 1, p)
return seg + 1
var mseg := points.size() - 2 - seg # lower index of the mirrored segment
var mp := _mirror(p)
if seg <= mseg:
points.insert(mseg + 1, mp) # insert the higher index first so the lower stays valid
points.insert(seg + 1, p)
return seg + 1
points.insert(seg + 1, p)
points.insert(mseg + 1, mp)
return seg + 2 # the mirror insert below seg+1 shifted our vertex up by one
## Deletes a point from a layer, taking its mirror partner with it when symmetry-lock is on.
func _delete(points: Array, key: String, i: int) -> void:
if _ordered(key):
var j := points.size() - 1 - i
points.remove_at(i)
if _sym_on and j != i:
points.remove_at(j - 1 if j > i else j)
return
var m := _mirror(points[i])
points.remove_at(i)
if _sym_on:
var p := _index_near(points, m, PAIR_EPS)
if p >= 0:
points.remove_at(p)
## Snaps the point under the cursor onto the y=x axis (its closest point on the line). Leaves any
## mirror partner alone — on-axis points are their own mirror, so a stray twin can be deleted by
## hand if symmetry-lock had made one.
func _axis_snap() -> void:
var points := _active_points()
var i := _nearest(points, _cursor_world())
if i < 0:
return
_push_undo()
var p: Vector2 = points[i]
var c := (p.x + p.y) * 0.5
points[i] = _place(Vector2(c, c))
## The index of `i`'s mirror partner in `points`, or -1. River and nexus pair by order (the
## point and its end-to-end opposite); the others pair by position (the point nearest `i`'s
## mirror). A point that is its own partner returns -1.
func _partner(points: Array, i: int, key: String) -> int:
if _ordered(key):
var j := points.size() - 1 - i
return j if j != i else -1
return _index_near_except(points, _mirror(points[i]), PAIR_EPS, i)
func _mirror(p: Vector2) -> Vector2:
return Vector2(p.y, p.x) # reflection across the line y = x
func _on_axis(p: Vector2) -> bool:
return absf(p.x - p.y) < AXIS_EPS
func _push_undo() -> void:
var snap := {}
for k in _model:
snap[k] = _model[k].duplicate()
_undo.append(snap)
if _undo.size() > UNDO_MAX:
_undo.pop_front()
func _undo_last() -> void:
if _undo.is_empty():
return
_model = _undo.pop_back()
_drag = -1
# --- geometry queries --------------------------------------------------------------------------
## The cursor's position on the ground plane (y = 0), from the camera ray under the mouse.
func _cursor_world() -> Vector2:
var m := get_root().get_mouse_position()
var from := _cam.project_ray_origin(m)
var dir := _cam.project_ray_normal(m)
if absf(dir.y) < 0.00001:
return Vector2.ZERO
var t := -from.y / dir.y
var p := from + dir * t
return Vector2(p.x, p.z)
## A cursor position resolved to where a point should sit: snapped to the grid (if on) and
## clamped inside the playable bounds.
func _place(w: Vector2) -> Vector2:
var p := w
if _snap_on:
p = Vector2(roundf(p.x / SNAP) * SNAP, roundf(p.y / SNAP) * SNAP)
return MapData.clamp_to_bounds(p)
## Index of the point within `GRAB` reach of the cursor, nearest first, or -1.
func _nearest(points: Array, cursor: Vector2) -> int:
return _index_near(points, cursor, GRAB)
func _index_near(points: Array, target: Vector2, radius: float) -> int:
return _index_near_except(points, target, radius, -1)
func _index_near_except(points: Array, target: Vector2, radius: float, skip: int) -> int:
var best := -1
var best_d := radius
for i in points.size():
if i == skip:
continue
var d: float = points[i].distance_to(target)
if d <= best_d:
best_d = d
best = i
return best
## Index of the river segment (seg..seg+1) within `INSERT_DIST` of the cursor, nearest first, or
## -1 — where a click splits the polyline rather than extending it.
func _nearest_segment(points: Array, cursor: Vector2) -> int:
var best := -1
var best_d := INSERT_DIST
for k in points.size() - 1:
var d := _segment_distance(points[k], points[k + 1], cursor)
if d <= best_d:
best_d = d
best = k
return best
func _segment_distance(a: Vector2, b: Vector2, p: Vector2) -> float:
var ab := b - a
var len2 := ab.length_squared()
var t := 0.0 if len2 <= 0.0 else clampf((p - a).dot(ab) / len2, 0.0, 1.0)
return p.distance_to(a + ab * t)
func _active_layer() -> Dictionary:
return LAYERS[_layer]
func _active_points() -> Array:
return _model[_active_layer()["key"]]
# --- drawing -----------------------------------------------------------------------------------
## Clears and rebuilds every visual each frame: grid, axis, bounds, reference lanes, all layers'
## points, the active layer's draggable handles + indices, and the mirror ghosts. Cheap enough
## for a few dozen nodes; redrawing live keeps hover and drag responsive.
func _redraw() -> void:
for c in _decor.get_children():
c.free()
_grid()
_axis()
_bounds()
var cursor := _cursor_world()
for i in LAYERS.size():
var layer: Dictionary = LAYERS[i]
var points: Array = _model[layer["key"]]
var active: bool = i == _layer
var poly: bool = layer.get("poly", false)
if poly:
_polyline(points, 72.0, layer["color"].darkened(0.2))
var hover := _nearest(points, cursor) if active else -1
for j in points.size():
var radius := 180.0 if active else 108.0
var color: Color = Color(1.0, 0.95, 0.4) if active and j == hover else layer["color"]
_disc(points[j], radius, color, 6.0 if active else 3.0)
if active and poly:
_index(points[j], j)
# The ghost previews the predicted mirror only when symmetry-lock is off — with it on the
# mirror points are real, so a ghost would just double them.
if active and _ghost_on and not _sym_on:
for p in points:
if not _on_axis(p):
_disc(_mirror(p), 144.0, layer["color"], 4.0, 0.30)
## The coordinate grid: a faint line every 500 units, brighter through the origin — so a spot
## on screen maps back to MapData numbers.
func _grid() -> void:
for k in range(-4800, 4801, 1200):
var major := k == 0
var color := Color(0.45, 0.45, 0.52) if major else Color(0.20, 0.21, 0.25)
var w := 24.0 if major else 7.0
_strip(Vector3(float(k), 1.0, 0.0), Vector3(w, 1.0, 9600.0), color)
_strip(Vector3(0.0, 1.0, float(k)), Vector3(9600.0, 1.0, w), color)
## The mirror axis: the TL-BR diagonal (sim y = x), drawn bright so the symmetry reads.
func _axis() -> void:
var diag := sqrt(2.0) * 9600.0
var strip := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(24.0, 1.0, diag)
strip.mesh = box
strip.material_override = _mat(Color(0.85, 0.75, 0.35))
strip.position = Vector3(0.0, 1.5, 0.0)
strip.rotation.y = deg_to_rad(45.0) # along (−2000,−2000)→(2000,2000)
_decor.add_child(strip)
## The playable-bounds outline, so the map edges read.
func _bounds() -> void:
var b := MapData.BOUNDS
var color := Color(0.35, 0.35, 0.40)
_strip(Vector3(b.position.x, 1.0, b.get_center().y), Vector3(20.0, 1.0, b.size.y), color)
_strip(Vector3(b.end.x, 1.0, b.get_center().y), Vector3(20.0, 1.0, b.size.y), color)
_strip(Vector3(b.get_center().x, 1.0, b.position.y), Vector3(b.size.x, 1.0, 20.0), color)
_strip(Vector3(b.get_center().x, 1.0, b.end.y), Vector3(b.size.x, 1.0, 20.0), color)
## Draws a polyline as a ribbon of connected segments — the river course or a reference lane.
func _polyline(points: Array, width: float, color: Color) -> void:
for i in points.size() - 1:
var a: Vector2 = points[i]
var b: Vector2 = points[i + 1]
var delta := b - a
var length := delta.length()
if length <= 0.0:
continue
var strip := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(width, 1.0, length)
strip.mesh = box
strip.material_override = _mat(color)
var mid := (a + b) * 0.5
strip.position = Vector3(mid.x, 2.0, mid.y)
strip.rotation.y = atan2(delta.x, delta.y)
_decor.add_child(strip)
func _strip(pos: Vector3, size: Vector3, color: Color) -> void:
var strip := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = size
strip.mesh = box
strip.material_override = _mat(color)
strip.position = pos
_decor.add_child(strip)
func _disc(pos: Vector2, radius: float, color: Color, lift := 3.0, alpha := 1.0) -> void:
var disc := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = radius
cyl.bottom_radius = radius
cyl.height = 1.0
disc.mesh = cyl
disc.material_override = _mat(Color(color.r, color.g, color.b, alpha))
disc.position = Vector3(pos.x, lift, pos.y)
_decor.add_child(disc)
func _index(pos: Vector2, n: int) -> void:
var label := Label3D.new()
label.text = str(n)
label.font_size = 216
label.modulate = Color.WHITE
label.position = Vector3(pos.x, 10.0, pos.y)
label.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
_decor.add_child(label)
func _mat(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
if color.a < 1.0:
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
return mat
## The on-screen overlay: the layer tabs (each in its own colour, the active one bold), a live
## coordinate readout of the cursor and the hovered point, the toggle states, and the controls.
func _update_hud() -> void:
var tabs := PackedStringArray()
for i in LAYERS.size():
var layer: Dictionary = LAYERS[i]
var hexc: String = layer["color"].to_html(false)
var name := "%d %s(%d)" % [i + 1, layer["label"], _model[layer["key"]].size()]
if i == _layer:
tabs.append("[b][color=#%s]▸%s[/color][/b]" % [hexc, name])
else:
tabs.append("[color=#%s]%s[/color]" % [hexc, name])
var points := _active_points()
var cur := _place(_cursor_world())
var read := "cursor (%d, %d)" % [int(cur.x), int(cur.y)]
var hi := _nearest(points, _cursor_world())
if hi >= 0:
read += " point[%d] (%d, %d)" % [hi, int(points[hi].x), int(points[hi].y)]
var flags := "sym %s(S) snap %s(G) ghost %s(H)" % [
_onoff(_sym_on), _onoff(_snap_on), _onoff(_ghost_on)
]
var ctl := "click add/drag | click line=insert | right-del | A axis | Z undo"
var ctl2 := "wheel/middrag/R camera | W write | ESC quit"
_hud.text = " ".join(tabs) + "\n" + read + "\n" + flags + "\n" + ctl + " | " + ctl2
func _onoff(b: bool) -> String:
return "ON" if b else "off"
# --- write-back --------------------------------------------------------------------------------
## Writes every layer back into the matching const in map_data.gd, after saving a .bak. Each
## const is rewritten body-only: the `## doc` block and the `const … = [` / `]` lines are kept,
## and the element lines between them are replaced with the live points. (Per-element inline
## comments inside an array are not preserved — re-add any once the shape is final.)
func _write_back() -> void:
var f := FileAccess.open(MAP_PATH, FileAccess.READ)
if f == null:
push_error("map editor: cannot read %s" % MAP_PATH)
return
var src := f.get_as_text()
f.close()
var bak := FileAccess.open(MAP_PATH + ".bak", FileAccess.WRITE)
bak.store_string(src)
bak.close()
for layer in LAYERS:
src = _replace_const(src, layer["const_name"], _model[layer["key"]])
var w := FileAccess.open(MAP_PATH, FileAccess.WRITE)
w.store_string(src)
w.close()
print("map editor: wrote %s (backup: map_data.gd.bak)" % MAP_PATH)
## Replaces the element lines of `const <name>: Array[Vector2] = [ … ]` with `points`, keeping
## the declaration line, the closing `]`, and everything outside the array untouched.
func _replace_const(src: String, name: String, points: Array) -> String:
var lines := src.split("\n")
var start := -1
for i in lines.size():
if lines[i].strip_edges().begins_with("const " + name):
start = i
break
if start == -1:
push_warning("map editor: const %s not found — skipped" % name)
return src
var endi := -1
for i in range(start + 1, lines.size()):
if lines[i].strip_edges() == "]":
endi = i
break
if endi == -1:
push_warning("map editor: closing ] for %s not found — skipped" % name)
return src
var out := PackedStringArray()
for i in start + 1: # keep lines 0..start (the doc block + the `= [` line)
out.append(lines[i])
for p in points:
out.append("\tVector2(%s, %s)," % [_num(p.x), _num(p.y)])
for i in range(endi, lines.size()): # keep the `]` and everything after
out.append(lines[i])
return "\n".join(out)
func _num(v: float) -> String:
return "%.1f" % v
# --- input edge detection ----------------------------------------------------------------------
## True only on the frame `code` transitions from up to down — so a held key fires once.
func _tap(code: int) -> bool:
var down := Input.is_physical_key_pressed(code)
var was: bool = _prev_keys.get(code, false)
_prev_keys[code] = down
return down and not was
## Catches discrete mouse-wheel events (which have no held state to poll) and forwards them to
## the editor as zoom. The SceneTree script gets no input events itself; a node in the tree does.
class WheelCatcher:
extends Node
var editor
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
editor._zoom(-1)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
editor._zoom(1)
added tools/map_editor.gd.uid
@@ -0,0 +1 @@
uid://tnysu6gho30n
added tools/map_shot.gd
@@ -0,0 +1,115 @@
extends SceneTree
## Top-down map preview: renders the map straight overhead and saves it as a PNG, so the
## map can be reshaped by editing MapData coordinates and seeing the result without playing.
##
## Run it:
## godot --path . -s tools/map_shot.gd
## then open the written file (printed at the end). Edit src/sim/map_data.gd, run again.
const IMG := 1200 # output is a square IMG x IMG png
const VIEW := 10080.0 # world units the camera frames (a touch past the 9600-wide bounds)
const OUT := "res://map_preview.png"
var _frames := 0
func _initialize() -> void:
DisplayServer.window_set_size(Vector2i(IMG, IMG))
var world := Node3D.new()
get_root().add_child(world)
# A flat overhead orthographic camera: x stays x, the sim's y reads as screen-down,
# so the picture matches a top-down map drawn on paper.
var cam := Camera3D.new()
cam.projection = Camera3D.PROJECTION_ORTHOGONAL
cam.size = VIEW
cam.position = Vector3(0.0, 6000.0, 0.0)
cam.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
cam.far = 20000.0
world.add_child(cam)
cam.make_current()
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
world.add_child(light)
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = MapData.BOUNDS.size
ground.mesh = plane
ground.material_override = _mat(Color(0.12, 0.13, 0.15))
world.add_child(ground)
# A coordinate grid under everything: a faint line every 500 world units and a brighter
# pair through the origin, so a spot in the picture maps back to the numbers in MapData.
_grid(world)
# The real map decor (lanes, river, camps) drawn by the same code the game uses,
# plus a marker on each nexus so the bases read.
MapView.build(world)
for team in MapData.NEXUS_POSITIONS.size():
var color := Color(0.30, 0.60, 1.0) if team == 0 else Color(1.0, 0.42, 0.38)
_disc(world, MapData.nexus_for_team(team), 360.0, color)
for tower in MapData.tower_positions(team):
_disc(world, tower, 168.0, color.darkened(0.3))
## Waits a few frames so the scene is drawn, then grabs the framebuffer and saves it.
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 4:
return false
var image := get_root().get_texture().get_image()
image.save_png(OUT)
print("map preview saved -> ", ProjectSettings.globalize_path(OUT))
return true
## Draws the coordinate grid: a faint strip every 500 units along both axes, the origin pair
## brighter, plus a label at each axis end so the numbers are readable off the picture.
func _grid(parent: Node3D) -> void:
for k in range(-4800, 4801, 1200):
var major := k == 0
var color := Color(0.55, 0.55, 0.62) if major else Color(0.22, 0.23, 0.27)
var width := 29.0 if major else 10.0
_strip(parent, Vector3(float(k), 1.0, 0.0), Vector3(width, 1.0, 9600.0), color)
_strip(parent, Vector3(0.0, 1.0, float(k)), Vector3(9600.0, 1.0, width), color)
_label(parent, Vector3(4440.0, 8.0, 216.0), "+x")
_label(parent, Vector3(216.0, 8.0, 4440.0), "+y")
func _strip(parent: Node3D, pos: Vector3, size: Vector3, color: Color) -> void:
var strip := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = size
strip.mesh = box
strip.material_override = _mat(color)
strip.position = pos
parent.add_child(strip)
func _label(parent: Node3D, pos: Vector3, text: String) -> void:
var label := Label3D.new()
label.text = text
label.font_size = 312
label.position = pos
label.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
parent.add_child(label)
func _disc(parent: Node3D, pos: Vector2, radius: float, color: Color) -> void:
var disc := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = radius
cyl.bottom_radius = radius
cyl.height = 1.0
disc.mesh = cyl
disc.material_override = _mat(color)
disc.position = Vector3(pos.x, 5.0, pos.y)
parent.add_child(disc)
func _mat(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
return mat
added tools/map_shot.gd.uid
@@ -0,0 +1 @@
uid://uvjs321snbwn