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)