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)