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.box((60.0, GROUND_HALF_THICKNESS * 2.0, 60.0)),
98 material=ground_mat,
99 )
100 )
101
102 # Decorative pillars just outside the playable corners, matching the
103 # upstream "Cylinders" group. Scenery only: they have no collider.
104 cyl_mat = Material(colour=(0.635, 0.21, 0.024, 1.0), roughness=0.6, metallic=0.0)
105 cyl_mesh = Mesh.cylinder(radius=0.5, height=2.0, segments=20)
106 pillar_x = ARENA_HALF_X + 1.5
107 pillar_z = ARENA_HALF_Z + 1.5
108 for cx, cz in [(-pillar_x, -pillar_z), (pillar_x, -pillar_z), (-pillar_x, pillar_z), (pillar_x, pillar_z)]:
109 self.add_child(
110 MeshInstance3D(
111 name=f"Pillar_{cx:+.0f}_{cz:+.0f}",
112 mesh=cyl_mesh,
113 material=cyl_mat,
114 position=Vec3(cx, GROUND_Y + 1.0, cz),
115 )
116 )
117
118 # Faint border highlight strip: gives the arena edge a visible cue
119 # so the "fall off if you wander too far" rule is legible.
120 edge_mat = Material(
121 colour=(0.95, 0.85, 0.3, 1.0),
122 emissive_colour=(0.95, 0.85, 0.3, 1.5),
123 roughness=0.5,
124 metallic=0.0,
125 )
126 edge_thickness = 0.25
127 edge_height = 0.05
128 for sx, sz, lx, lz in [
129 (0.0, ARENA_HALF_Z, ARENA_HALF_X * 2 + edge_thickness, edge_thickness),
130 (0.0, -ARENA_HALF_Z, ARENA_HALF_X * 2 + edge_thickness, edge_thickness),
131 (ARENA_HALF_X, 0.0, edge_thickness, ARENA_HALF_Z * 2 + edge_thickness),
132 (-ARENA_HALF_X, 0.0, edge_thickness, ARENA_HALF_Z * 2 + edge_thickness),
133 ]:
134 self.add_child(
135 MeshInstance3D(
136 name=f"Edge_{sx}_{sz}",
137 mesh=Mesh.box((lx, edge_height, lz)),
138 material=edge_mat,
139 position=Vec3(sx, GROUND_Y + edge_height * 0.5, sz),
140 )
141 )
142
143
144def random_spawn_position() -> Vec3:
145 """Return a position on the playable arena perimeter."""
146 edge = random.choice(("north", "south", "east", "west"))
147 y = GROUND_Y + 0.5
148 if edge == "north":
149 return Vec3(random.uniform(-ARENA_HALF_X, ARENA_HALF_X), y, -ARENA_HALF_Z)
150 if edge == "south":
151 return Vec3(random.uniform(-ARENA_HALF_X, ARENA_HALF_X), y, ARENA_HALF_Z)
152 if edge == "east":
153 return Vec3(ARENA_HALF_X, y, random.uniform(-ARENA_HALF_Z, ARENA_HALF_Z))
154 return Vec3(-ARENA_HALF_X, y, random.uniform(-ARENA_HALF_Z, ARENA_HALF_Z))
155
156
157def is_off_arena(pos: Vec3, margin: float = DESPAWN_MARGIN) -> bool:
158 """True if a node has wandered ``margin`` metres past the arena boundary.
159
160 The default is the generous despawn radius used to retire stray mobs; the
161 player is checked with the much tighter :data:`PLAYER_MARGIN` so the run
162 ends at the marked edge rather than somewhere out of shot.
163 """
164 return abs(pos.x) > ARENA_HALF_X + margin or abs(pos.z) > ARENA_HALF_Z + margin or pos.y < -10.0
165
166
167def camera_offset() -> tuple[Vec3, Vec3]:
168 """Return (camera_position, look_target) for the fixed isometric camera.
169
170 The distance is solved from the arena size rather than hard-coded: it is
171 the smallest one that still puts the near boundary (the edge hardest to
172 keep on screen at a downward pitch) on the bottom of the vertical field of
173 view. The playable width then fits across the 4:3 framing this port ships
174 with; a much narrower window crops the left and right edges.
175 """
176 reach = ARENA_HALF_Z + PLAYER_MARGIN
177 half_fov = math.radians(CAMERA_FOV) * 0.5
178 distance = reach * math.sin(CAMERA_PITCH) / math.tan(half_fov) + reach * math.cos(CAMERA_PITCH)
179 pos = Vec3(0.0, distance * math.sin(CAMERA_PITCH), distance * math.cos(CAMERA_PITCH))
180 return pos, Vec3(0.0, 0.0, 0.0)