quake-rb 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76a4b7671e6182b04a0257b7ad6a229cefc9e5dc2e3c763529327e376b910f80
4
- data.tar.gz: d855437be72bfaec56741b68bfd0497c578d21e67501c8383164f3c8ebd9abd6
3
+ metadata.gz: cc20d9efbfef9cb809f874ff1b00448ba06e58e0fec05d756ed98b9a1104a5e7
4
+ data.tar.gz: 63a4aa60adb6ec8d5ab243195afdf22de9a405c6050d7a1997d50d6def884b6d
5
5
  SHA512:
6
- metadata.gz: 83f646634ab2dafd538fa3ab73814218871e01f0e09c2a10108f5a487e4d70476cfba4cccd15269887ee8f9ce05720b0c65739dd76f793b959f419b4bd1ce5ea
7
- data.tar.gz: 877663d022ddfedb05cd9c0b3b630d04dcfdef084345f0e4183ea22733d351f87968105e062567efa090ca3d147ad8df8d9c46dbfdb65f3f8a71ec60761cfa61
6
+ metadata.gz: e3d20548d6671537bd2f22dd78bbbd09d6b6c6527ff0ea36a12450afbfcf02f73df3bd9b122cf4fc6aaffd893039e9a1516b21c0813fd7787fcdbdcff0f727ab
7
+ data.tar.gz: 7ab39626446b4998f5f8df44ae5945e497496651030d922828841bff5bdc908a325f189a4cd7a8ad19db90f47a45789a60b66401c5667ff4ff8ca09210b10df0
data/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # quake-rb
2
+
3
+ A port of the [Quake (1996)](https://en.wikipedia.org/wiki/Quake_(video_game)) engine to Ruby. Reads original `.pak`/`.bsp`/`.mdl` assets and renders them via OpenGL + SDL2.
4
+
5
+ ![quake-rb running e1m1](docs/screenshot.png)
6
+
7
+ ## Why
8
+
9
+ Mostly as a counterpart to [doom](https://github.com/khasinski/doom) — to see how far you can take id Software's classic engines in plain Ruby, and to learn the BSP/PVS/lightmap pipeline by writing it from the spec rather than reading the C.
10
+
11
+ ## Quick start
12
+
13
+ ```sh
14
+ bundle install
15
+ bundle exec bin/quake
16
+ ```
17
+
18
+ On first run, with no `data/id1/pak0.pak` present, you'll be asked to download the freely-redistributable shareware Quake (Episode 1, ~17 MB). It's pulled from `archive.org/quakeshareware/QUAKE_SW.zip` and extracted into `./data/id1/pak0.pak`. You can also drop your own registered `pak0.pak` (and `pak1.pak`) in there manually.
19
+
20
+ System requirements:
21
+
22
+ - Ruby 3.1+
23
+ - SDL2 (`brew install sdl2` / `apt install libsdl2-dev`)
24
+ - OpenGL 2.1
25
+ - `unzip` (used by the shareware fetcher)
26
+
27
+ ## Controls
28
+
29
+ | Key | Action |
30
+ |----------------|--------|
31
+ | WASD | Move |
32
+ | Mouse | Look |
33
+ | Space | Jump |
34
+ | C | Crouch |
35
+ | 1–8 / scroll | Weapon select |
36
+ | N | Toggle noclip |
37
+ | L | Dump player pose + save a screenshot to `debug/shots/` (debug aid) |
38
+ | Esc | Quit |
39
+
40
+ ## What works
41
+
42
+ - BSP loading + face extraction with texture coordinates
43
+ - Texture mapping with the original 256-colour palette
44
+ - Static lightmaps from the lighting lump
45
+ - PVS culling, including:
46
+ - leaf 0 fallback (treat solid leaf as all-visible, à la TyrQuake)
47
+ - FatPVS expansion when the camera leaf touches a liquid surface
48
+ - **vis-through-water**: liquid faces in PVS bring the leaves on the other side of their plane into view, so translucent water shows the cave bed instead of clear-color void
49
+ - Sky rendering: two-layer scrolling cloud + back layer, PVS-culled
50
+ - Water/slime/lava: turbsin-warped texture, alpha-blended translucency, backface culling for the duplicate front/back face pairs Quake's BSP compiler emits
51
+ - Brush entities (doors, plats, buttons, triggers) with their own transforms
52
+ - MDL alias models with frame interpolation
53
+ - Particle effects + viewmodel + HUD
54
+ - Player physics: walk, crouch, jump, friction, water levels, platform riding
55
+ - Item pickups, weapon switching, Quake-style player weapon firing, shareware audio mixer
56
+ - Common QuakeC trigger behavior at engine level, including target firing,
57
+ killtarget, counters, hurt/push/teleport triggers, and changelevel requests
58
+
59
+ ## What doesn't (yet)
60
+
61
+ - Multiplayer / netcode
62
+ - QuakeC server program — game logic is faked at engine level
63
+ - Full savegame/spawn parameter preservation across episode/level transitions
64
+ - Mirrors, particle warp, dynamic lights
65
+ - Sky cube / skybox replacement
66
+ - Demo playback
67
+
68
+ ## Maps
69
+
70
+ The shareware PAK ships:
71
+
72
+ `start`, `e1m1`, `e1m2`, `e1m3`, `e1m4`, `e1m5`, `e1m6`, `e1m7`, `e1m8`
73
+
74
+ Pass `-map maps/e1m3.bsp` to load a different one.
75
+
76
+ ## Architecture
77
+
78
+ ```
79
+ lib/quake/
80
+ bsp/ reader, types, face vertices, PVS / FatPVS / vis-through-water
81
+ pak/ pak0.pak reader
82
+ mdl/ alias model reader
83
+ wad/ gfx.wad (HUD/menu graphics) reader
84
+ palette.rb 256-colour palette → RGBA
85
+ math/vec3.rb plain Ruby vector
86
+ physics/ hull trace + Player (walk/swim/jump/noclip)
87
+ game/
88
+ engine.rb orchestrates renderers + physics + entity simulation
89
+ brush_entities entity-driven door/plat/button state machines
90
+ item_pickups touchable items
91
+ player_state weapons, ammo, health, armor
92
+ renderer/
93
+ gl_textured opaque world brushes (with lightmap pass)
94
+ gl_brush_model sub-model brushes (doors etc.) at entity transforms
95
+ gl_alias_model .mdl monsters + items
96
+ gl_water *water0/*slime0/*lava0/*teleport with turbsin warp
97
+ gl_sky two-layer scrolling sky
98
+ gl_lightmap lightmap atlas builder
99
+ gl_texture_manager mipmapped diffuse texture cache
100
+ gl_particles explosion/spark sprites
101
+ gl_viewmodel first-person weapon
102
+ gl_hud statusbar + numbers
103
+ sound/ SDL_mixer-backed sound effects
104
+ pak_downloader.rb shareware fetcher
105
+ version.rb VERSION = "0.1.0"
106
+ bin/
107
+ quake interactive game
108
+ quake-debug headless DSL runner (`teleport`, `screenshot`, `dump` etc.)
109
+ test/ minitest suite
110
+ debug/scripts/ ad-hoc investigation scripts (gitignored)
111
+ ```
112
+
113
+ The headless runner is genuinely useful for working on the engine without a window — see `debug/scripts/*` for examples (water visibility traces, leaf inspection, BSP dumps).
114
+
115
+ ## Development
116
+
117
+ ```sh
118
+ # Run the tests
119
+ bundle exec ruby -Itest -Ilib -e 'Dir["test/**/*_test.rb"].each { |f| require File.expand_path(f) }'
120
+
121
+ # Run a headless debug script (renders to a hidden window)
122
+ bundle exec bin/quake-debug debug/scripts/water_real.rb
123
+
124
+ # Run the game with YJIT
125
+ bundle exec bin/quake --yjit
126
+ ```
127
+
128
+ ## Status
129
+
130
+ This is a hobby project. It can comfortably walk you through `e1m1`, fire Quake-style player weapons, pick up items, and use a growing subset of QuakeC brush/trigger behavior. The QuakeC server program is still not implemented, so game logic is ported incrementally at engine level and monsters don't fight back yet.
131
+
132
+ ## License
133
+
134
+ GPL-2.0-only, matching the original Quake source release.
135
+
136
+ The shareware Quake data is © 1996 id Software, freely redistributable. The registered Quake data (if you supply your own `pak0.pak` + `pak1.pak`) is not redistributable; you must own a copy.
data/bin/quake CHANGED
@@ -65,9 +65,12 @@ keys = {}
65
65
  running = true
66
66
  last_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
67
 
68
- puts "Controls: WASD = move, Mouse = look, Space = jump, C = crouch, N = noclip, 1-8/scroll = weapons, ESC = quit"
68
+ puts "Controls: WASD = move, Mouse = look, LMB/Ctrl = fire, Space = jump, C = crouch, N = noclip, 1-8/scroll = weapons, +/- = HUD size, ESC = quit"
69
69
  puts "Movement mode: #{noclip ? 'noclip' : 'normal'}"
70
70
 
71
+ mouse_attack = false
72
+ key_attack = false
73
+
71
74
  while running
72
75
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
76
  dt = [now - last_time, 0.05].min
@@ -106,6 +109,12 @@ while running
106
109
  "shot=#{shot}"
107
110
  end
108
111
 
112
+ if scancode == SDL::SCANCODE_EQUALS
113
+ engine.adjust_viewsize(10) # sizeup: less HUD
114
+ elsif scancode == SDL::SCANCODE_MINUS
115
+ engine.adjust_viewsize(-10) # sizedown: more HUD
116
+ end
117
+
109
118
  weapon_slot = case scancode
110
119
  when SDL::SCANCODE_1 then 1
111
120
  when SDL::SCANCODE_2 then 2
@@ -132,9 +141,17 @@ while running
132
141
  engine.viewmodel&.set_weapon(engine.player_state.current_weapon_model)
133
142
  when SDL::MOUSEMOTION
134
143
  engine.player.rotate(event[:motion][:xrel], event[:motion][:yrel])
144
+ when SDL::MOUSEBUTTONDOWN
145
+ mouse_attack = true if event[:button][:button] == SDL::BUTTON_LEFT
146
+ when SDL::MOUSEBUTTONUP
147
+ mouse_attack = false if event[:button][:button] == SDL::BUTTON_LEFT
135
148
  end
136
149
  end
137
150
 
151
+ # Ctrl is Quake's classic +attack; combine with the mouse button
152
+ key_attack = keys[SDL::SCANCODE_LCTRL] || keys[SDL::SCANCODE_RCTRL] || false
153
+ engine.set_attack(mouse_attack || key_attack)
154
+
138
155
  engine.set_keys(keys)
139
156
  engine.tick(dt)
140
157
  engine.swap_buffers
@@ -30,23 +30,39 @@ module Quake
30
30
 
31
31
  def parse
32
32
  parse_header
33
+ @textures = parse_textures
34
+ vertices = parse_vertices
35
+ edges = parse_edges
36
+ faces = parse_faces
37
+ surfedges = parse_surfedges
38
+ planes = parse_planes
39
+ nodes = parse_nodes
40
+ leafs = parse_leafs
41
+ clipnodes = parse_clipnodes
42
+ texinfo = parse_texinfo
43
+ faces = build_surface_data(vertices, edges, faces, surfedges, texinfo)
44
+ models = parse_models
45
+ marksurfaces = parse_marksurfaces
46
+ entities = parse_entities
47
+ visibility = parse_visibility
48
+ lighting = parse_lighting
33
49
 
34
50
  Level.new(
35
- vertices: parse_vertices,
36
- edges: parse_edges,
37
- faces: parse_faces,
38
- surfedges: parse_surfedges,
39
- planes: parse_planes,
40
- nodes: parse_nodes,
41
- leafs: parse_leafs,
42
- clipnodes: parse_clipnodes,
43
- texinfo: parse_texinfo,
44
- models: parse_models,
45
- marksurfaces: parse_marksurfaces,
46
- entities: parse_entities,
47
- visibility: parse_visibility,
48
- lighting: parse_lighting,
49
- textures: parse_textures
51
+ vertices: vertices,
52
+ edges: edges,
53
+ faces: faces,
54
+ surfedges: surfedges,
55
+ planes: planes,
56
+ nodes: nodes,
57
+ leafs: leafs,
58
+ clipnodes: clipnodes,
59
+ texinfo: texinfo,
60
+ models: models,
61
+ marksurfaces: marksurfaces,
62
+ entities: entities,
63
+ visibility: visibility,
64
+ lighting: lighting,
65
+ textures: @textures
50
66
  )
51
67
  end
52
68
 
@@ -54,7 +70,9 @@ module Quake
54
70
 
55
71
  def parse_header
56
72
  version = @data[0, 4].unpack1("l<")
57
- raise "Unsupported BSP version: #{version} (expected #{BSP_VERSION})" unless version == BSP_VERSION
73
+ unless version == BSP_VERSION
74
+ raise "Mod_LoadBrushModel: wrong version number (#{version} should be #{BSP_VERSION})"
75
+ end
58
76
 
59
77
  NUM_LUMPS.times do |i|
60
78
  offset = 4 + i * 8
@@ -63,13 +81,16 @@ module Quake
63
81
  end
64
82
  end
65
83
 
66
- def lump_data(index)
84
+ def lump_data(index, record_size: nil)
67
85
  offset, length = @lumps[index]
86
+ if record_size && (length % record_size) != 0
87
+ raise "MOD_LoadBmodel: funny lump size"
88
+ end
68
89
  @data[offset, length]
69
90
  end
70
91
 
71
92
  def parse_vertices
72
- data = lump_data(LUMP_VERTICES)
93
+ data = lump_data(LUMP_VERTICES, record_size: 12)
73
94
  count = data.bytesize / 12
74
95
  result = Array.new(count)
75
96
  count.times do |i|
@@ -80,7 +101,7 @@ module Quake
80
101
  end
81
102
 
82
103
  def parse_edges
83
- data = lump_data(LUMP_EDGES)
104
+ data = lump_data(LUMP_EDGES, record_size: 4)
84
105
  count = data.bytesize / 4
85
106
  result = Array.new(count)
86
107
  count.times do |i|
@@ -91,8 +112,9 @@ module Quake
91
112
  end
92
113
 
93
114
  def parse_faces
94
- data = lump_data(LUMP_FACES)
115
+ data = lump_data(LUMP_FACES, record_size: 20)
95
116
  count = data.bytesize / 20
117
+ @face_count = count
96
118
  result = Array.new(count)
97
119
  count.times do |i|
98
120
  raw = data[i * 20, 20]
@@ -102,19 +124,78 @@ module Quake
102
124
  plane_index: plane_idx, side: side,
103
125
  first_edge: first_edge, num_edges: num_edges,
104
126
  texinfo_index: texinfo_idx, styles: [s0, s1, s2, s3],
105
- light_offset: light_ofs
127
+ light_offset: light_ofs, flags: 0, texture_mins: [0, 0],
128
+ extents: [0, 0]
106
129
  )
107
130
  end
108
131
  result
109
132
  end
110
133
 
134
+ def build_surface_data(vertices, edges, faces, surfedges, texinfo)
135
+ faces.map do |face|
136
+ info = texinfo[face.texinfo_index]
137
+ next face unless info
138
+
139
+ texture_mins, extents = surface_extents(
140
+ vertices, edges, face, surfedges, info
141
+ )
142
+ if (info.flags & 1) == 0 && extents.any? { |extent| extent > 256 }
143
+ raise "Bad surface extents"
144
+ end
145
+
146
+ flags = face.side.zero? ? 0 : Face::SURF_PLANEBACK
147
+ texture = @textures[info.miptex_index] if @textures
148
+ name = texture&.name.to_s
149
+ if name.start_with?("sky")
150
+ flags |= Face::SURF_DRAWSKY | Face::SURF_DRAWTILED
151
+ elsif name.start_with?("*")
152
+ flags |= Face::SURF_DRAWTURB | Face::SURF_DRAWTILED
153
+ texture_mins = [-8192, -8192]
154
+ extents = [16384, 16384]
155
+ end
156
+
157
+ face.with(flags: flags, texture_mins: texture_mins, extents: extents)
158
+ end
159
+ end
160
+
161
+ def surface_extents(vertices, edges, face, surfedges, texinfo)
162
+ mins = [999999.0, 999999.0]
163
+ maxs = [-99999.0, -99999.0]
164
+
165
+ face.num_edges.times do |i|
166
+ surfedge = surfedges[face.first_edge + i]
167
+ edge = edges[surfedge.abs]
168
+ vertex_index = surfedge >= 0 ? edge.v0 : edge.v1
169
+ vertex = vertices[vertex_index]
170
+
171
+ values = [
172
+ vertex.dot(texinfo.s_vec) + texinfo.s_offset,
173
+ vertex.dot(texinfo.t_vec) + texinfo.t_offset
174
+ ]
175
+ 2.times do |axis|
176
+ mins[axis] = values[axis] if values[axis] < mins[axis]
177
+ maxs[axis] = values[axis] if values[axis] > maxs[axis]
178
+ end
179
+ end
180
+
181
+ texture_mins = Array.new(2)
182
+ extents = Array.new(2)
183
+ 2.times do |axis|
184
+ bmin = (mins[axis] / 16.0).floor
185
+ bmax = (maxs[axis] / 16.0).ceil
186
+ texture_mins[axis] = bmin * 16
187
+ extents[axis] = (bmax - bmin) * 16
188
+ end
189
+ [texture_mins, extents]
190
+ end
191
+
111
192
  def parse_surfedges
112
- data = lump_data(LUMP_SURFEDGES)
193
+ data = lump_data(LUMP_SURFEDGES, record_size: 4)
113
194
  data.unpack("l<*")
114
195
  end
115
196
 
116
197
  def parse_planes
117
- data = lump_data(LUMP_PLANES)
198
+ data = lump_data(LUMP_PLANES, record_size: 20)
118
199
  count = data.bytesize / 20
119
200
  result = Array.new(count)
120
201
  count.times do |i|
@@ -127,7 +208,7 @@ module Quake
127
208
  end
128
209
 
129
210
  def parse_nodes
130
- data = lump_data(LUMP_NODES)
211
+ data = lump_data(LUMP_NODES, record_size: 24)
131
212
  count = data.bytesize / 24
132
213
  result = Array.new(count)
133
214
  count.times do |i|
@@ -147,7 +228,7 @@ module Quake
147
228
  end
148
229
 
149
230
  def parse_leafs
150
- data = lump_data(LUMP_LEAFS)
231
+ data = lump_data(LUMP_LEAFS, record_size: 28)
151
232
  count = data.bytesize / 28
152
233
  result = Array.new(count)
153
234
  count.times do |i|
@@ -168,7 +249,7 @@ module Quake
168
249
  end
169
250
 
170
251
  def parse_clipnodes
171
- data = lump_data(LUMP_CLIPNODES)
252
+ data = lump_data(LUMP_CLIPNODES, record_size: 8)
172
253
  count = data.bytesize / 8
173
254
  result = Array.new(count)
174
255
  count.times do |i|
@@ -179,13 +260,19 @@ module Quake
179
260
  end
180
261
 
181
262
  def parse_texinfo
182
- data = lump_data(LUMP_TEXINFO)
263
+ data = lump_data(LUMP_TEXINFO, record_size: 40)
183
264
  count = data.bytesize / 40
184
265
  result = Array.new(count)
185
266
  count.times do |i|
186
267
  raw = data[i * 40, 40]
187
268
  floats = raw[0, 32].unpack("e8")
188
269
  miptex, flags = raw[32, 8].unpack("l<2")
270
+ if @texture_count && miptex >= @texture_count
271
+ raise "miptex >= loadmodel->numtextures"
272
+ end
273
+ if @texture_count.nil? || @textures[miptex].nil?
274
+ flags = 0
275
+ end
189
276
  result[i] = TexInfo.new(
190
277
  s_vec: Math::Vec3.new(floats[0], floats[1], floats[2]),
191
278
  s_offset: floats[3],
@@ -198,7 +285,7 @@ module Quake
198
285
  end
199
286
 
200
287
  def parse_models
201
- data = lump_data(LUMP_MODELS)
288
+ data = lump_data(LUMP_MODELS, record_size: 64)
202
289
  count = data.bytesize / 64
203
290
  result = Array.new(count)
204
291
  count.times do |i|
@@ -206,8 +293,12 @@ module Quake
206
293
  floats = raw[0, 36].unpack("e9")
207
294
  ints = raw[36, 28].unpack("l<7")
208
295
  result[i] = Model.new(
209
- mins: Math::Vec3.new(floats[0], floats[1], floats[2]),
210
- maxs: Math::Vec3.new(floats[3], floats[4], floats[5]),
296
+ mins: Math::Vec3.new(
297
+ floats[0] - 1.0, floats[1] - 1.0, floats[2] - 1.0
298
+ ),
299
+ maxs: Math::Vec3.new(
300
+ floats[3] + 1.0, floats[4] + 1.0, floats[5] + 1.0
301
+ ),
211
302
  origin: Math::Vec3.new(floats[6], floats[7], floats[8]),
212
303
  head_nodes: ints[0..3],
213
304
  vis_leafs: ints[4],
@@ -218,30 +309,41 @@ module Quake
218
309
  end
219
310
 
220
311
  def parse_marksurfaces
221
- data = lump_data(LUMP_MARKSURFACES)
222
- data.unpack("v*")
312
+ data = lump_data(LUMP_MARKSURFACES, record_size: 2)
313
+ data.unpack("v*").each do |face_index|
314
+ if face_index >= @face_count
315
+ raise "Mod_ParseMarksurfaces: bad surface number"
316
+ end
317
+ end
223
318
  end
224
319
 
225
320
  def parse_entities
226
- lump_data(LUMP_ENTITIES)&.force_encoding("ASCII")&.delete("\0") || ""
321
+ data = lump_data(LUMP_ENTITIES)
322
+ data&.empty? ? nil : data&.force_encoding("ASCII")
227
323
  end
228
324
 
229
325
  def parse_visibility
230
- lump_data(LUMP_VISIBILITY) || ""
326
+ data = lump_data(LUMP_VISIBILITY)
327
+ data&.empty? ? nil : data
231
328
  end
232
329
 
233
330
  def parse_lighting
234
- lump_data(LUMP_LIGHTING) || ""
331
+ data = lump_data(LUMP_LIGHTING)
332
+ data&.empty? ? nil : data
235
333
  end
236
334
 
237
335
  def parse_textures
238
336
  data = lump_data(LUMP_TEXTURES)
239
- return [] if data.nil? || data.bytesize < 4
337
+ if data.nil? || data.bytesize < 4
338
+ @texture_count = nil
339
+ return []
340
+ end
240
341
 
241
342
  num_miptex = data[0, 4].unpack1("l<")
343
+ @texture_count = num_miptex
242
344
  offsets = data[4, num_miptex * 4].unpack("l<#{num_miptex}")
243
345
 
244
- offsets.map do |ofs|
346
+ textures = offsets.map do |ofs|
245
347
  if ofs < 0
246
348
  nil
247
349
  else
@@ -249,15 +351,116 @@ module Quake
249
351
  next nil if raw.nil? || raw.bytesize < 40
250
352
  name = raw[0, 16].unpack1("Z16")
251
353
  width, height = raw[16, 8].unpack("V2")
354
+ if (width & 15) != 0 || (height & 15) != 0
355
+ raise "Texture #{name} is not 16 aligned"
356
+ end
252
357
  mip_offsets = raw[24, 16].unpack("V4")
253
358
  # Extract mip level 0 pixel data (8-bit indexed)
254
359
  pixel_offset = ofs + mip_offsets[0]
255
360
  pixel_count = width * height
256
361
  pixels = data[pixel_offset, pixel_count]
257
362
  MipTex.new(name: name, width: width, height: height,
258
- offsets: mip_offsets, pixels: pixels)
363
+ offsets: mip_offsets, pixels: pixels,
364
+ anim_total: 0, anim_min: 0, anim_max: 0,
365
+ anim_next: nil, alternate_anims: nil)
259
366
  end
260
367
  end
368
+ sequence_texture_animations(textures)
369
+ textures
370
+ end
371
+
372
+ def sequence_texture_animations(textures)
373
+ sequenced = {}
374
+ textures.each_with_index do |texture, index|
375
+ next unless texture&.name&.start_with?("+")
376
+
377
+ suffix = texture.name[2..]
378
+ next if sequenced[suffix]
379
+
380
+ anims = Array.new(10)
381
+ alt_anims = Array.new(10)
382
+ max = add_texture_animation_frame(anims, alt_anims, texture, texture.name)
383
+ altmax = texture_animation_alt_frame?(texture.name[1]) ? max : 0
384
+ max = 0 if altmax.positive?
385
+
386
+ textures[(index + 1)..]&.each do |other|
387
+ next unless other&.name&.start_with?("+")
388
+ next unless other.name[2..] == suffix
389
+
390
+ frame = add_texture_animation_frame(anims, alt_anims, other, texture.name)
391
+ if texture_animation_alt_frame?(other.name[1])
392
+ altmax = frame if frame > altmax
393
+ elsif frame > max
394
+ max = frame
395
+ end
396
+ end
397
+
398
+ max.times do |frame|
399
+ raise "Missing frame #{frame} of #{texture.name}" unless anims[frame]
400
+ end
401
+ altmax.times do |frame|
402
+ raise "Missing frame #{frame} of #{texture.name}" unless alt_anims[frame]
403
+ end
404
+
405
+ link_texture_animation_frames(textures, anims, max, alt_anims, altmax)
406
+ sequenced[suffix] = true
407
+ end
408
+ end
409
+
410
+ ANIM_CYCLE = 2
411
+
412
+ def link_texture_animation_frames(textures, anims, max, alt_anims, altmax)
413
+ anim_indices = anims.map { |texture| textures.index(texture) }
414
+ alt_anim_indices = alt_anims.map { |texture| textures.index(texture) }
415
+
416
+ max.times do |frame|
417
+ texture = anims[frame]
418
+ index = anim_indices[frame]
419
+ textures[index] = texture.with(
420
+ anim_total: max * ANIM_CYCLE,
421
+ anim_min: frame * ANIM_CYCLE,
422
+ anim_max: (frame + 1) * ANIM_CYCLE,
423
+ anim_next: anim_indices[(frame + 1) % max],
424
+ alternate_anims: altmax.positive? ? alt_anim_indices[0] : nil
425
+ )
426
+ end
427
+
428
+ altmax.times do |frame|
429
+ texture = alt_anims[frame]
430
+ index = alt_anim_indices[frame]
431
+ textures[index] = texture.with(
432
+ anim_total: altmax * ANIM_CYCLE,
433
+ anim_min: frame * ANIM_CYCLE,
434
+ anim_max: (frame + 1) * ANIM_CYCLE,
435
+ anim_next: alt_anim_indices[(frame + 1) % altmax],
436
+ alternate_anims: max.positive? ? anim_indices[0] : nil
437
+ )
438
+ end
439
+ end
440
+
441
+ def add_texture_animation_frame(anims, alt_anims, texture, error_name)
442
+ frame = texture_animation_frame(texture.name[1], error_name)
443
+ if texture_animation_alt_frame?(texture.name[1])
444
+ alt_anims[frame] = texture
445
+ else
446
+ anims[frame] = texture
447
+ end
448
+ frame + 1
449
+ end
450
+
451
+ def texture_animation_frame(char, name)
452
+ normalized = char&.upcase
453
+ if normalized&.between?("0", "9")
454
+ normalized.ord - "0".ord
455
+ elsif normalized&.between?("A", "J")
456
+ normalized.ord - "A".ord
457
+ else
458
+ raise "Bad animating texture #{name}"
459
+ end
460
+ end
461
+
462
+ def texture_animation_alt_frame?(char)
463
+ char&.upcase&.between?("A", "J")
261
464
  end
262
465
  end
263
466
  end
@@ -4,18 +4,44 @@ module Quake
4
4
  module Bsp
5
5
  Edge = Data.define(:v0, :v1)
6
6
  Face = Data.define(:plane_index, :side, :first_edge, :num_edges,
7
- :texinfo_index, :styles, :light_offset)
8
- Plane = Data.define(:normal, :dist, :type)
7
+ :texinfo_index, :styles, :light_offset,
8
+ :flags, :texture_mins, :extents)
9
+ Face.const_set(:SURF_PLANEBACK, 2)
10
+ Face.const_set(:SURF_DRAWSKY, 4)
11
+ Face.const_set(:SURF_DRAWTURB, 0x10)
12
+ Face.const_set(:SURF_DRAWTILED, 0x20)
13
+ Plane = Data.define(:normal, :dist, :type) do
14
+ def signbits
15
+ bits = 0
16
+ bits |= 1 if normal.x.negative?
17
+ bits |= 2 if normal.y.negative?
18
+ bits |= 4 if normal.z.negative?
19
+ bits
20
+ end
21
+ end
9
22
  Node = Data.define(:plane_index, :children, :mins, :maxs,
10
23
  :first_face, :num_faces)
11
24
  Leaf = Data.define(:contents, :vis_offset, :mins, :maxs,
12
25
  :first_marksurface, :num_marksurfaces, :ambient_levels)
13
26
  ClipNode = Data.define(:plane_index, :children)
27
+ Hull = Data.define(:first_clipnode, :last_clipnode,
28
+ :clip_mins, :clip_maxs)
14
29
  TexInfo = Data.define(:s_vec, :s_offset, :t_vec, :t_offset,
15
- :miptex_index, :flags)
30
+ :miptex_index, :flags) do
31
+ def mipadjust
32
+ scale = (s_vec.length + t_vec.length) / 2.0
33
+ return 4 if scale < 0.32
34
+ return 3 if scale < 0.49
35
+ return 2 if scale < 0.99
36
+
37
+ 1
38
+ end
39
+ end
16
40
  Model = Data.define(:mins, :maxs, :origin, :head_nodes,
17
41
  :vis_leafs, :first_face, :num_faces)
18
- MipTex = Data.define(:name, :width, :height, :offsets, :pixels)
42
+ MipTex = Data.define(:name, :width, :height, :offsets, :pixels,
43
+ :anim_total, :anim_min, :anim_max, :anim_next,
44
+ :alternate_anims)
19
45
 
20
46
  # Pre-computed surface data for rendering
21
47
  Surface = Data.define(:vertices, :texcoords, :texinfo_index)
@@ -25,6 +51,24 @@ module Quake
25
51
  :planes, :nodes, :leafs, :clipnodes,
26
52
  :texinfo, :models, :marksurfaces,
27
53
  :entities, :visibility, :lighting, :textures
28
- )
54
+ ) do
55
+ def hulls
56
+ [
57
+ nil,
58
+ Hull.new(
59
+ first_clipnode: 0,
60
+ last_clipnode: clipnodes.size - 1,
61
+ clip_mins: Math::Vec3.new(-16.0, -16.0, -24.0),
62
+ clip_maxs: Math::Vec3.new(16.0, 16.0, 32.0)
63
+ ),
64
+ Hull.new(
65
+ first_clipnode: 0,
66
+ last_clipnode: clipnodes.size - 1,
67
+ clip_mins: Math::Vec3.new(-32.0, -32.0, -24.0),
68
+ clip_maxs: Math::Vec3.new(32.0, 32.0, 64.0)
69
+ )
70
+ ]
71
+ end
72
+ end
29
73
  end
30
74
  end