summaryrefslogtreecommitdiffhomepage
path: root/game/ballpit_demo.rb
blob: 1c1b3d57811a7aad5d2619c066d1a0f3b69e09df (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# raylib-jamstack: playable 3D character demo — raylib (render) + Jolt (physics).
#
#   ./zig-out/bin/game game/ballpit_demo.rb        (desktop)
#   (web: preloaded; runnable as /game/ballpit_demo.rb)
#
# A third-person character walks on a big flat plane with a square PIT cut into
# the middle. Inside the pit are dynamic balls the player can shove around (but
# the balls can't push the player back — the player is a kinematic
# CharacterVirtual). A short stair-ramp on one side of the pit lets the player
# climb back out. A big KINEMATIC sphere orbits the pit on the plane and bowls
# the player out of its way (the player can't push it back).
#
# Controls:  WASD move (camera-relative)   SPACE jump   Mouse look
#            hold SHIFT to sprint           ESC quit
#
# Layout (top-down, +x right, +z toward camera-back; y is up):
#   - ground top surface at y = 0
#   - square pit opening of side PIT_SIZE centred at origin, floor at y = -PIT_DEPTH
#   - stairs descend into the pit from the +x edge

# ----------------------------------------------------------------- constants --
GROUND_EXTENT = 24.0        # half-size of the whole plane
PIT_SIZE      = 9.0         # full side length of the square pit opening
PIT_HALF      = PIT_SIZE / 2.0
PIT_DEPTH     = 3.0         # how far below the top surface the pit floor sits
GROUND_THICK  = 1.0         # thickness of the top ground slabs
WALL_THICK    = 0.6

PLAYER_RADIUS = 0.4
PLAYER_HALFH  = 0.5         # capsule cylinder half-height
PLAYER_FOOT_TO_CENTRE = PLAYER_HALFH + PLAYER_RADIUS  # capsule centre above feet

MOVE_SPEED  = 6.0
SPRINT_MULT = 1.7
JUMP_SPEED  = 7.5
GRAVITY     = 22.0

# ----------------------------------------------------------------- window ------
Rl.init_window(1024, 600, "raylib-jamstack: ball pit character (Jolt)")
Rl.target_fps = 60
Rl.disable_cursor   # lock + hide the mouse for FPS-style look

# ----------------------------------------------------------------- physics -----
world = Jolt::World.new(gravity: [0, -GRAVITY, 0])

# Boxes we want to draw later: each entry is [centre(Array), size(Array), color].
boxes = []

def static_box(world, boxes, cx, cy, cz, sx, sy, sz, color, friction: 0.8)
  world.body(shape: Jolt.box(sx, sy, sz), position: [cx, cy, cz],
             motion: Jolt::STATIC, friction: friction)
  boxes << [[cx, cy, cz], [sx, sy, sz], color]
end

GROUND_COL = Rl::Color.new(70, 80, 70, 255)
PIT_COL    = Rl::Color.new(55, 52, 64, 255)
WALL_COL   = Rl::Color.new(90, 86, 70, 255)
STAIR_COL  = Rl::Color.new(120, 100, 70, 255)

# --- top ground built as a FRAME of 4 slabs around the square pit opening ---
# Each slab spans the full width in one axis and fills the margin on the other.
# Top surface sits at y = 0, so slab centre y = -GROUND_THICK/2.
gy   = -GROUND_THICK / 2.0
side = GROUND_EXTENT - PIT_HALF          # depth of each frame slab
off  = PIT_HALF + side / 2.0             # centre offset of each frame slab
full = GROUND_EXTENT * 2.0
# +z and -z slabs (full width in x, "side" deep in z)
static_box(world, boxes, 0.0,  gy,  off, full, GROUND_THICK, side, GROUND_COL)
static_box(world, boxes, 0.0,  gy, -off, full, GROUND_THICK, side, GROUND_COL)
# +x and -x slabs (only the pit width in z so they don't overlap the others)
static_box(world, boxes,  off, gy, 0.0, side, GROUND_THICK, PIT_SIZE, GROUND_COL)
static_box(world, boxes, -off, gy, 0.0, side, GROUND_THICK, PIT_SIZE, GROUND_COL)

# --- pit floor (lowered) ---
pit_floor_y = -PIT_DEPTH - GROUND_THICK / 2.0
static_box(world, boxes, 0.0, pit_floor_y, 0.0, PIT_SIZE, GROUND_THICK, PIT_SIZE, PIT_COL, friction: 0.9)

# --- pit walls (vertical slabs lining the inside of the opening) ---
# The +x wall is split into two short segments leaving a gap where the staircase
# joins the pit, so the player can walk straight off the bottom step.
wall_h    = PIT_DEPTH
wall_cy   = -PIT_DEPTH / 2.0
STAIR_W   = 3.4                       # width (z-span) of the staircase strip
gap_half  = STAIR_W / 2.0
seg_len   = (PIT_SIZE - STAIR_W) / 2.0
seg_off   = gap_half + seg_len / 2.0
# -z and +z walls (run along x), full width
static_box(world, boxes, 0.0, wall_cy,  PIT_HALF, PIT_SIZE, wall_h, WALL_THICK, WALL_COL)
static_box(world, boxes, 0.0, wall_cy, -PIT_HALF, PIT_SIZE, wall_h, WALL_THICK, WALL_COL)
# -x wall (run along z), full inner width
static_box(world, boxes, -PIT_HALF, wall_cy, 0.0, WALL_THICK, wall_h, PIT_SIZE - 2 * WALL_THICK, WALL_COL)
# +x wall split around the staircase gap
static_box(world, boxes, PIT_HALF, wall_cy,  seg_off, WALL_THICK, wall_h, seg_len, WALL_COL)
static_box(world, boxes, PIT_HALF, wall_cy, -seg_off, WALL_THICK, wall_h, seg_len, WALL_COL)

# --- staircase on the +x side: a compact flight from pit floor up to the surface ---
# Steps are axis-aligned boxes with rise <= 0.4 m, which the CharacterVirtual
# climbs via stair-stepping (walkStairsStepUp = 0.4 m). Each step is a box from
# the pit floor up to its top surface; they march in +x and only span STAIR_W in
# z, so the rest of the pit floor stays open for the balls.
STEP_RISE  = 0.375
STEP_RUN   = 0.55
n_steps    = (PIT_DEPTH / STEP_RISE).ceil    # enough steps to reach the surface
floor_top  = pit_floor_y + GROUND_THICK / 2.0
n_steps.times do |i|
  top_y   = -PIT_DEPTH + (i + 1) * STEP_RISE          # this step's top surface
  top_y   = 0.0 if top_y > 0.0
  height  = top_y - floor_top                          # from pit floor up to step top
  cy      = floor_top + height / 2.0
  # innermost (lowest) step nearest the pit centre; flight climbs toward +x edge
  cx      = (PIT_HALF - STEP_RUN / 2.0) - (n_steps - 1 - i) * STEP_RUN
  static_box(world, boxes, cx, cy, 0.0, STEP_RUN, height, STAIR_W, STAIR_COL, friction: 0.95)
end

# ----------------------------------------------------------------- balls --------
PALETTE = [Rl::RED, Rl::ORANGE, Rl::GOLD, Rl::LIME, Rl::SKYBLUE,
           Rl::BLUE, Rl::VIOLET, Rl::PINK]
balls = []   # each: [body, radius, color]
def spawn_ball(world, balls, pos, radius, color)
  b = world.body(shape: Jolt.sphere(radius), position: pos,
                 motion: Jolt::DYNAMIC, restitution: 0.25, friction: 0.5)
  balls << [b, radius, color]
end

# A loose grid of balls resting on the -x half of the pit floor, clear of the
# staircase (which occupies the +x half).
ci = 0
ball_top = pit_floor_y + GROUND_THICK / 2.0
[-3.4, -2.4, -1.4, -0.4].each do |bx|
  [-3.0, -1.5, 0.0, 1.5, 3.0].each do |bz|
    r = 0.45
    spawn_ball(world, balls, [bx, ball_top + r + 0.05, bz], r, PALETTE[ci % PALETTE.length])
    ci += 1
  end
end

# ----------------------------------------------------------------- orbiter ------
# A big KINEMATIC sphere circling on the flat plane around the pit. It pushes the
# player (kinematic vs character penetration recovery) but the player can't push
# it. We drive it by setting linear_velocity to the circle tangent each frame.
ORBIT_RADIUS = GROUND_EXTENT - 6.0
ORBIT_SPEED  = 5.0          # m/s along the circle
ORBIT_R      = 1.6          # sphere radius
orbit_y      = ORBIT_R - 0.1   # rolls along on the top surface (y=0)
orbit_angle  = 0.0
orbiter = world.body(shape: Jolt.sphere(ORBIT_R),
                     position: [ORBIT_RADIUS, orbit_y, 0.0],
                     motion: Jolt::KINEMATIC, friction: 0.3)

# ----------------------------------------------------------------- player -------
player = world.character(shape: Jolt.capsule(PLAYER_HALFH, PLAYER_RADIUS),
                         position: [0.0, PLAYER_FOOT_TO_CENTRE + 0.2, GROUND_EXTENT - 5.0],
                         max_slope_deg: 50.0, mass: 80.0)
player.max_strength = 6000.0   # strong enough to shove the heavy default-density balls

world.optimize_broad_phase

# ----------------------------------------------------------------- camera -------
cam = Rl::Camera3D.new(
  Rl::Vector3.new(0, 6, 12),
  Rl::Vector3.new(0, 1, 0),
  Rl::Vector3.new(0, 1, 0),
  55.0,
  Rl::CAMERA_PERSPECTIVE
)
cam_yaw   = Math::PI            # look toward -z initially (toward the pit)
cam_pitch = -0.35
CAM_DIST  = 7.5
MOUSE_SENS = 0.0032
PITCH_MIN = -1.3
PITCH_MAX =  0.4

# ----------------------------------------------------------------- helpers ------
def clampf(v, lo, hi); v < lo ? lo : (v > hi ? hi : v); end

# ----------------------------------------------------------------- main loop ----
Rl.while_window_open do
  dt = Rl.frame_time
  dt = 1.0 / 60.0 if dt <= 0.0 || dt > 0.1   # clamp for stability / first frame

  # --- mouse look ---
  # Browsers only grant pointer lock from a user gesture, so (re)lock on click;
  # on desktop disable_cursor at startup already locked it. Only rotate the
  # camera while actually locked, so the view doesn't jump before the first click
  # or after ESC releases the lock.
  Rl.disable_cursor if Rl.mouse_button_pressed?(Rl::MOUSE_BUTTON_LEFT) && !Rl.cursor_hidden?
  if Rl.cursor_hidden?
    md = Rl.get_mouse_delta
    cam_yaw   -= md.x * MOUSE_SENS   # mouse-right looks right
    cam_pitch  = clampf(cam_pitch - md.y * MOUSE_SENS, PITCH_MIN, PITCH_MAX)
  end

  # camera-relative ground basis from yaw. right = cross(forward, up), matching
  # raylib's own camera-right convention so D strafes screen-right.
  fwd_x   =  Math.sin(cam_yaw)
  fwd_z   =  Math.cos(cam_yaw)
  right_x = -Math.cos(cam_yaw)
  right_z =  Math.sin(cam_yaw)

  # --- WASD movement intent (camera-relative, on the ground plane) ---
  # NOTE: a trailing `if` only guards the LAST statement on the line, so each key
  # must gate BOTH axis components explicitly (else the X component never applies).
  mx = 0.0
  mz = 0.0
  if Rl.key_down?(:w) then mx += fwd_x;   mz += fwd_z;   end
  if Rl.key_down?(:s) then mx -= fwd_x;   mz -= fwd_z;   end
  if Rl.key_down?(:a) then mx -= right_x; mz -= right_z; end
  if Rl.key_down?(:d) then mx += right_x; mz += right_z; end
  len = Math.sqrt(mx * mx + mz * mz)
  if len > 0.0001
    mx /= len
    mz /= len
  end
  speed = MOVE_SPEED * (Rl.key_down?(:left_shift) ? SPRINT_MULT : 1.0)

  # --- vertical velocity: jump if grounded, else integrate gravity ---
  v  = player.velocity
  vy = if player.on_ground?
         Rl.key_pressed?(:space) ? JUMP_SPEED : 0.0
       else
         v.y - GRAVITY * dt
       end
  player.velocity = [mx * speed, vy, mz * speed]
  player.update(dt)

  # --- drive the orbiting kinematic sphere along its circle (tangent velocity) ---
  orbit_angle += (ORBIT_SPEED / ORBIT_RADIUS) * dt
  tang_x = -Math.sin(orbit_angle)
  tang_z =  Math.cos(orbit_angle)
  orbiter.linear_velocity = [tang_x * ORBIT_SPEED, 0.0, tang_z * ORBIT_SPEED]

  # --- advance physics ---
  world.step(dt)

  # keep the orbiter pinned to its circle + height (kinematic drift correction)
  oa = orbit_angle
  orbiter.set_transform(position: [Math.cos(oa) * ORBIT_RADIUS, orbit_y,
                                   Math.sin(oa) * ORBIT_RADIUS])

  # --- third-person follow camera ---
  p = player.position
  cp = Math.cos(cam_pitch)
  eye_x = p.x - fwd_x * CAM_DIST * cp
  eye_z = p.z - fwd_z * CAM_DIST * cp
  eye_y = p.y + 2.2 - Math.sin(cam_pitch) * CAM_DIST
  cam.position = Rl::Vector3.new(eye_x, eye_y, eye_z)
  cam.target   = Rl::Vector3.new(p.x, p.y + 1.0, p.z)

  # --- render ---
  Rl.draw(clear_color: Rl::Color.new(135, 160, 190, 255)) do
    Rl.begin_mode3d(cam)

    # static level geometry (frame, pit, walls, stairs)
    boxes.each do |centre, size, color|
      Rl.draw_cube_v(Rl::Vector3.new(centre[0], centre[1], centre[2]),
                     Rl::Vector3.new(size[0], size[1], size[2]), color)
      Rl.draw_cube_wires_v(Rl::Vector3.new(centre[0], centre[1], centre[2]),
                           Rl::Vector3.new(size[0], size[1], size[2]),
                           Rl::Color.new(30, 30, 30, 120))
    end

    # balls
    balls.each do |body, radius, color|
      Rl.draw_sphere(body.position, radius, color)
    end

    # orbiting sphere
    Rl.draw_sphere(orbiter.position, ORBIT_R, Rl::Color.new(220, 60, 60, 255))
    Rl.draw_sphere_wires(orbiter.position, ORBIT_R, 10, 10, Rl::Color.new(60, 0, 0, 180))

    # player capsule (feet at position - PLAYER_FOOT_TO_CENTRE..., caps offset by radius)
    pc = player.position
    bottom = Rl::Vector3.new(pc.x, pc.y - PLAYER_HALFH, pc.z)
    top    = Rl::Vector3.new(pc.x, pc.y + PLAYER_HALFH, pc.z)
    pcol = player.on_ground? ? Rl::Color.new(60, 200, 240, 255) : Rl::Color.new(240, 200, 60, 255)
    Rl.draw_capsule(bottom, top, PLAYER_RADIUS, 12, 8, pcol)
    Rl.draw_capsule_wires(bottom, top, PLAYER_RADIUS, 12, 8, Rl::Color.new(20, 40, 50, 200))

    Rl.end_mode3d

    # --- HUD ---
    Rl.draw_text(text: "WASD move   SPACE jump   SHIFT sprint   Mouse look",
                 x: 12, y: 12, font_size: 20, color: Rl::RAYWHITE)
    Rl.draw_text(text: "Push the balls in the pit. Dodge the rolling sphere. Use the stairs to climb out.",
                 x: 12, y: 38, font_size: 16, color: Rl::Color.new(230, 230, 230, 255))
    gs = player.ground_state.to_s
    Rl.draw_text(text: "ground: #{gs}", x: 12, y: 62, font_size: 16, color: Rl::LIGHTGRAY)
    Rl.draw_fps(Rl.screen_width - 95, 12)

    # prompt to engage pointer lock (mainly for the browser, which needs a click)
    unless Rl.cursor_hidden?
      Rl.draw_text(text: "Click to look around", x: Rl.screen_width / 2 - 120,
                   y: Rl.screen_height / 2, font_size: 26, color: Rl::RAYWHITE)
    end
  end
end