nodes/arena.pyΒΆ
Part of Squash the Creeps.
1"""Arena: static ground body, decorative pillars, lighting and sky.
2
3Visual approximation of the upstream `Main.tscn` scene: a 60x60 ground
4plate with four decorative pillars just outside the playable corners, a
5bright directional light angled across the field, and a `WorldEnvironment`
6carrying a gradient sky, fog and ACES tonemapping. The ground is a static
7physics body, so the player's `move_and_slide` has something to land on.
8The arena also owns the playable extents, the spawn-edge sampler and the
9camera placement that frames them.
10"""
11
12from __future__ import annotations
13
14import math
15import random
16
17from simvx.core import (
18 BoxShape3D,
19 DirectionalLight3D,
20 Material,
21 Mesh,
22 MeshInstance3D,
23 Node3D,
24 PhysicsBody3D,
25 Vec3,
26 WorldEnvironment,
27)
28
29# The Godot scene stands on a 60x60 ground plate, but only the middle of it is
30# in play: creeps spawn on this boundary and the camera is framed around it.
31ARENA_HALF_X = 14.0
32ARENA_HALF_Z = 15.5
33DESPAWN_MARGIN = 6.0
34
35GROUND_Y = 0.0 # top of the ground sits at y=0; the box extends downward.
36GROUND_HALF_THICKNESS = 1.0
37
38# How far past the marked boundary the player may stray before the run ends.
39# Small, so the fall happens at the yellow line the camera is framed around.
40PLAYER_MARGIN = 1.0
41
42# Fixed isometric camera, matching the upstream framing.
43CAMERA_PITCH = math.radians(45.0)
44CAMERA_FOV = 48.6
45
46# Collision layers. A pair of bodies collides only when each opts into the
47# other's layer, which is what keeps the three roles apart here: the player
48# slides on the ground and nothing else, mobs block nothing at all, and the
49# player finds mobs with an overlap query masked to LAYER_MOB alone.
50LAYER_GROUND = 1 << 0
51LAYER_PLAYER = 1 << 1
52LAYER_MOB = 1 << 2
53
54
55class Arena(Node3D):
56 """Static scenery + lighting."""
57
58 def __init__(self, **kwargs):
59 super().__init__(**kwargs)
60
61 # Sky / environment.
62 env = self.add_child(WorldEnvironment(name="WorldEnvironment"))
63 env.sky_mode = "colour" # vertical gradient between the two sky colours
64 env.sky_colour_top = (0.45, 0.6, 0.95, 1.0)
65 env.sky_colour_bottom = (0.85, 0.88, 0.92, 1.0)
66 env.ambient_light_colour = (0.55, 0.6, 0.7, 1.0)
67 env.ambient_light_energy = 0.45
68 env.tonemap_mode = "aces"
69 env.fog_enabled = True
70 env.fog_density = 0.012
71 env.fog_colour = (0.78, 0.82, 0.88)
72
73 # Sun.
74 sun = self.add_child(DirectionalLight3D(name="Sun"))
75 sun.position = Vec3(8.0, 14.0, 8.0)
76 sun.colour = (1.0, 0.96, 0.85)
77 sun.intensity = 1.4
78 sun.look_at(Vec3(0, 0, 0))
79
80 # Ground plate: a static body with a box collider, carrying the visible
81 # slab as a child mesh. The top face sits at GROUND_Y so the player's
82 # collide-and-slide comes to rest exactly on the visible surface.
83 ground_mat = Material(colour=(0.42, 0.55, 0.32, 1.0), roughness=0.95, metallic=0.0)
84 ground = self.add_child(
85 PhysicsBody3D(
86 name="Ground",
87 mode="static",
88 shape=BoxShape3D(half_extents=(30.0, GROUND_HALF_THICKNESS, 30.0)),
89 position=Vec3(0.0, GROUND_Y - GROUND_HALF_THICKNESS, 0.0),
90 collision_layer=LAYER_GROUND,
91 collision_mask=LAYER_PLAYER,
92 )
93 )
94 ground.add_child(
95 MeshInstance3D(
96 name="GroundMesh",
97 mesh=Mesh.cube(),
98 material=ground_mat,
99 scale=Vec3(60.0, GROUND_HALF_THICKNESS * 2.0, 60.0),
100 )
101 )
102
103 # Decorative pillars just outside the playable corners, matching the
104 # upstream "Cylinders" group. Scenery only: they have no collider.
105 cyl_mat = Material(colour=(0.635, 0.21, 0.024, 1.0), roughness=0.6, metallic=0.0)
106 cyl_mesh = Mesh.cylinder(radius=0.5, height=2.0, segments=20)
107 pillar_x = ARENA_HALF_X + 1.5
108 pillar_z = ARENA_HALF_Z + 1.5
109 for cx, cz in [(-pillar_x, -pillar_z), (pillar_x, -pillar_z), (-pillar_x, pillar_z), (pillar_x, pillar_z)]:
110 self.add_child(
111 MeshInstance3D(
112 name=f"Pillar_{cx:+.0f}_{cz:+.0f}",
113 mesh=cyl_mesh,
114 material=cyl_mat,
115 position=Vec3(cx, GROUND_Y + 1.0, cz),
116 )
117 )
118
119 # Faint border highlight strip: gives the arena edge a visible cue
120 # so the "fall off if you wander too far" rule is legible.
121 edge_mat = Material(
122 colour=(0.95, 0.85, 0.3, 1.0),
123 emissive_colour=(0.95, 0.85, 0.3, 1.5),
124 roughness=0.5,
125 metallic=0.0,
126 )
127 edge_thickness = 0.25
128 edge_height = 0.05
129 for sx, sz, lx, lz in [
130 (0.0, ARENA_HALF_Z, ARENA_HALF_X * 2 + edge_thickness, edge_thickness),
131 (0.0, -ARENA_HALF_Z, ARENA_HALF_X * 2 + edge_thickness, edge_thickness),
132 (ARENA_HALF_X, 0.0, edge_thickness, ARENA_HALF_Z * 2 + edge_thickness),
133 (-ARENA_HALF_X, 0.0, edge_thickness, ARENA_HALF_Z * 2 + edge_thickness),
134 ]:
135 self.add_child(
136 MeshInstance3D(
137 name=f"Edge_{sx}_{sz}",
138 mesh=Mesh.cube(),
139 material=edge_mat,
140 position=Vec3(sx, GROUND_Y + edge_height * 0.5, sz),
141 scale=Vec3(lx, edge_height, lz),
142 )
143 )
144
145
146def random_spawn_position() -> Vec3:
147 """Return a position on the playable arena perimeter."""
148 edge = random.choice(("north", "south", "east", "west"))
149 y = GROUND_Y + 0.5
150 if edge == "north":
151 return Vec3(random.uniform(-ARENA_HALF_X, ARENA_HALF_X), y, -ARENA_HALF_Z)
152 if edge == "south":
153 return Vec3(random.uniform(-ARENA_HALF_X, ARENA_HALF_X), y, ARENA_HALF_Z)
154 if edge == "east":
155 return Vec3(ARENA_HALF_X, y, random.uniform(-ARENA_HALF_Z, ARENA_HALF_Z))
156 return Vec3(-ARENA_HALF_X, y, random.uniform(-ARENA_HALF_Z, ARENA_HALF_Z))
157
158
159def is_off_arena(pos: Vec3, margin: float = DESPAWN_MARGIN) -> bool:
160 """True if a node has wandered ``margin`` metres past the arena boundary.
161
162 The default is the generous despawn radius used to retire stray mobs; the
163 player is checked with the much tighter :data:`PLAYER_MARGIN` so the run
164 ends at the marked edge rather than somewhere out of shot.
165 """
166 return abs(pos.x) > ARENA_HALF_X + margin or abs(pos.z) > ARENA_HALF_Z + margin or pos.y < -10.0
167
168
169def camera_offset() -> tuple[Vec3, Vec3]:
170 """Return (camera_position, look_target) for the fixed isometric camera.
171
172 The distance is solved from the arena size rather than hard-coded: it is
173 the smallest one that still puts the near boundary (the edge hardest to
174 keep on screen at a downward pitch) on the bottom of the vertical field of
175 view. The playable width then fits across the 4:3 framing this port ships
176 with; a much narrower window crops the left and right edges.
177 """
178 reach = ARENA_HALF_Z + PLAYER_MARGIN
179 half_fov = math.radians(CAMERA_FOV) * 0.5
180 distance = reach * math.sin(CAMERA_PITCH) / math.tan(half_fov) + reach * math.cos(CAMERA_PITCH)
181 pos = Vec3(0.0, distance * math.sin(CAMERA_PITCH), distance * math.cos(CAMERA_PITCH))
182 return pos, Vec3(0.0, 0.0, 0.0)