scad-gltf 0.1.4 → 0.2.2

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.
@@ -0,0 +1,2415 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ // Root project directory
7
+ const PROJECT_DIR = path.join(process.cwd(), "packman_3d");
8
+
9
+ // Helper to write files ensuring directories exist
10
+ function writeFile(filePath, content) {
11
+ const fullPath = path.join(PROJECT_DIR, filePath);
12
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
13
+ fs.writeFileSync(fullPath, content.trim() + "\n", "utf8");
14
+ console.log(`Created: ${filePath}`);
15
+ }
16
+
17
+ console.log(`\nInitializing Packman 3D project in: ${PROJECT_DIR}\n`);
18
+ fs.mkdirSync(PROJECT_DIR, { recursive: true });
19
+
20
+ // =========================================================================
21
+ // 1. Cargo.toml (Bevy 0.15 with audio features)
22
+ // =========================================================================
23
+ writeFile(
24
+ "Cargo.toml",
25
+ `
26
+ [package]
27
+ name = "packman-3d"
28
+ version = "0.1.0"
29
+ edition = "2021"
30
+
31
+ [dependencies]
32
+ bevy = { version = "0.15", default-features = true, features = ["wav"] }
33
+ rand = "0.8"
34
+ `,
35
+ );
36
+
37
+ // =========================================================================
38
+ // 2. build.rs (EXACT specification from instructions)
39
+ // =========================================================================
40
+ writeFile(
41
+ "build.rs",
42
+ `
43
+ use std::process::Command;
44
+
45
+ fn main() {
46
+ // Tell Cargo to re-run this script only if the 'scad' directory changes
47
+ println!("cargo::rerun-if-changed=scad");
48
+
49
+ // Ensure cross-platform compatibility for npm global binaries
50
+ let cmd = if cfg!(target_os = "windows") {
51
+ "scad-convert.cmd"
52
+ } else {
53
+ "scad-convert"
54
+ };
55
+
56
+ let status = Command::new(cmd)
57
+ .args(["./scad", "./assets/models", "--cache"])
58
+ .status()
59
+ .expect("Failed to execute scad-convert. Is scad-gltf installed globally?");
60
+
61
+ if !status.success() {
62
+ panic!("scad-convert failed with status: {}", status);
63
+ }
64
+ }
65
+ `,
66
+ );
67
+
68
+ // =========================================================================
69
+ // 3. OpenSCAD 3D Assets (Characters match tunnel width; Cyber Gate & Cherry)
70
+ // =========================================================================
71
+
72
+ // packman.scad: Animated chomping mouth, diameter 1.16
73
+ writeFile(
74
+ "scad/packman.scad",
75
+ `
76
+ $fn = 40;
77
+ $asa = 45;
78
+
79
+ anim_data = [
80
+ ["Chomp", [
81
+ ["MouthTop", [
82
+ [0.00, [0, 0, 0], [0, 0, 0]],
83
+ [0.18, [0, -32, 0], [0, 0, 0]],
84
+ [0.36, [0, 0, 0], [0, 0, 0]]
85
+ ]],
86
+ ["MouthBottom", [
87
+ [0.00, [0, 0, 0], [0, 0, 0]],
88
+ [0.18, [0, 32, 0], [0, 0, 0]],
89
+ [0.36, [0, 0, 0], [0, 0, 0]]
90
+ ]]
91
+ ]]
92
+ ];
93
+
94
+ armature(animations=anim_data) {
95
+ bone(name="BodyRoot", t=[0, 0, 0], r=[0, 0, 0]) {
96
+
97
+ // Upper jaw & head (r=0.58 -> diameter 1.16)
98
+ bone(name="MouthTop", t=[0, 0, 0], r=[0, 0, 0]) {
99
+ color([1.0, 0.86, 0.05], roughness=0.25, metalness=0.05, specularIntensity=0.8, emissive=[0.12, 0.10, 0.0], emissiveIntensity=1.0, $asa=45) {
100
+ intersection() {
101
+ sphere(r=0.58);
102
+ translate([-0.8, -0.8, 0.0])
103
+ cube([1.6, 1.6, 0.9]);
104
+ }
105
+ }
106
+
107
+ // Eye lenses
108
+ color([0.05, 0.05, 0.08], roughness=0.15, metalness=0.1, specularIntensity=0.9, $asa=30) {
109
+ translate([0.18, 0.36, 0.32])
110
+ sphere(r=0.10, $fn=24);
111
+ translate([0.18, -0.36, 0.32])
112
+ sphere(r=0.10, $fn=24);
113
+ }
114
+
115
+ // Highlights
116
+ color([1.0, 1.0, 1.0], roughness=0.1, metalness=0.0, emissive=[1.0, 1.0, 1.0], emissiveIntensity=2.0) {
117
+ translate([0.23, 0.38, 0.36])
118
+ sphere(r=0.032, $fn=16);
119
+ translate([0.23, -0.34, 0.36])
120
+ sphere(r=0.032, $fn=16);
121
+ }
122
+ }
123
+
124
+ // Lower jaw
125
+ bone(name="MouthBottom", t=[0, 0, 0], r=[0, 0, 0]) {
126
+ color([1.0, 0.86, 0.05], roughness=0.25, metalness=0.05, specularIntensity=0.8, emissive=[0.12, 0.10, 0.0], emissiveIntensity=1.0, $asa=45) {
127
+ intersection() {
128
+ sphere(r=0.58);
129
+ translate([-0.8, -0.8, -0.9])
130
+ cube([1.6, 1.6, 0.9]);
131
+ }
132
+ }
133
+
134
+ // Interior mouth lining
135
+ color([0.75, 0.1, 0.1], roughness=0.5, metalness=0.0) {
136
+ translate([0.08, 0.0, -0.03])
137
+ cylinder(r=0.44, h=0.04, center=true, $fn=24);
138
+ }
139
+ }
140
+ }
141
+ }
142
+ `,
143
+ );
144
+
145
+ // ghost_blinky.scad: Red Chaser Ghost ("Blinky") - diameter 1.16
146
+ writeFile(
147
+ "scad/ghost_blinky.scad",
148
+ `
149
+ $fn = 36;
150
+ $asa = 45;
151
+
152
+ anim_data = [
153
+ ["Float", [
154
+ ["GhostBody", [
155
+ [0.00, [0, 0, 0], [0, 0, 0.00]],
156
+ [0.35, [0, 0, 0], [0, 0, 0.06]],
157
+ [0.70, [0, 0, 0], [0, 0, 0.00]]
158
+ ]],
159
+ ["GhostEyes", [
160
+ [0.00, [0, 0, 0]],
161
+ [0.35, [0, 0, 6]],
162
+ [0.70, [0, 0, 0]]
163
+ ]]
164
+ ]]
165
+ ];
166
+
167
+ armature(animations=anim_data) {
168
+ bone(name="GhostRoot", t=[0, 0, 0], r=[0, 0, 0]) {
169
+ bone(name="GhostBody", t=[0, 0, 0], r=[0, 0, 0]) {
170
+ color([0.95, 0.12, 0.12], roughness=0.25, metalness=0.08, emissive=[0.40, 0.05, 0.05], emissiveIntensity=1.5, $asa=45) {
171
+ translate([0, 0, 0.22])
172
+ sphere(r=0.56);
173
+ translate([0, 0, -0.16])
174
+ cylinder(r=0.56, h=0.38, center=false);
175
+ for (i = [0:3]) {
176
+ rotate([0, 0, i * 90 + 45])
177
+ translate([0.35, 0.0, -0.24])
178
+ cylinder(r1=0.16, r2=0.20, h=0.22, center=true, $fn=18);
179
+ }
180
+ }
181
+
182
+ bone(name="GhostEyes", t=[0, 0, 0], r=[0, 0, 0]) {
183
+ color([0.96, 0.96, 1.0], roughness=0.15, metalness=0.0, $asa=35) {
184
+ translate([0.38, 0.20, 0.26])
185
+ scale([0.22, 0.18, 0.24])
186
+ sphere(r=1.0);
187
+ translate([0.38, -0.20, 0.26])
188
+ scale([0.22, 0.18, 0.24])
189
+ sphere(r=1.0);
190
+ }
191
+ color([0.1, 0.25, 0.9], roughness=0.1, metalness=0.2, emissive=[0.05, 0.15, 0.6], emissiveIntensity=1.5) {
192
+ translate([0.54, 0.20, 0.26])
193
+ scale([0.11, 0.11, 0.14])
194
+ sphere(r=1.0);
195
+ translate([0.54, -0.20, 0.26])
196
+ scale([0.11, 0.11, 0.14])
197
+ sphere(r=1.0);
198
+ }
199
+ }
200
+ }
201
+ }
202
+ }
203
+ `,
204
+ );
205
+
206
+ // ghost_pinky.scad: Pink Ambusher Ghost ("Pinky") - diameter 1.16
207
+ writeFile(
208
+ "scad/ghost_pinky.scad",
209
+ `
210
+ $fn = 36;
211
+ $asa = 45;
212
+
213
+ anim_data = [
214
+ ["Float", [
215
+ ["GhostBody", [
216
+ [0.00, [0, 0, 0], [0, 0, 0.00]],
217
+ [0.35, [0, 0, 0], [0, 0, 0.06]],
218
+ [0.70, [0, 0, 0], [0, 0, 0.00]]
219
+ ]],
220
+ ["GhostEyes", [
221
+ [0.00, [0, 0, 0]],
222
+ [0.35, [0, 0, 6]],
223
+ [0.70, [0, 0, 0]]
224
+ ]]
225
+ ]]
226
+ ];
227
+
228
+ armature(animations=anim_data) {
229
+ bone(name="GhostRoot", t=[0, 0, 0], r=[0, 0, 0]) {
230
+ bone(name="GhostBody", t=[0, 0, 0], r=[0, 0, 0]) {
231
+ color([1.0, 0.55, 0.78], roughness=0.25, metalness=0.08, emissive=[0.40, 0.12, 0.26], emissiveIntensity=1.5, $asa=45) {
232
+ translate([0, 0, 0.22])
233
+ sphere(r=0.56);
234
+ translate([0, 0, -0.16])
235
+ cylinder(r=0.56, h=0.38, center=false);
236
+ for (i = [0:3]) {
237
+ rotate([0, 0, i * 90 + 45])
238
+ translate([0.35, 0.0, -0.24])
239
+ cylinder(r1=0.16, r2=0.20, h=0.22, center=true, $fn=18);
240
+ }
241
+ }
242
+
243
+ bone(name="GhostEyes", t=[0, 0, 0], r=[0, 0, 0]) {
244
+ color([0.96, 0.96, 1.0], roughness=0.15, metalness=0.0, $asa=35) {
245
+ translate([0.38, 0.20, 0.26])
246
+ scale([0.22, 0.18, 0.24])
247
+ sphere(r=1.0);
248
+ translate([0.38, -0.20, 0.26])
249
+ scale([0.22, 0.18, 0.24])
250
+ sphere(r=1.0);
251
+ }
252
+ color([0.1, 0.25, 0.9], roughness=0.1, metalness=0.2, emissive=[0.05, 0.15, 0.6], emissiveIntensity=1.5) {
253
+ translate([0.54, 0.20, 0.26])
254
+ scale([0.11, 0.11, 0.14])
255
+ sphere(r=1.0);
256
+ translate([0.54, -0.20, 0.26])
257
+ scale([0.11, 0.11, 0.14])
258
+ sphere(r=1.0);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ `,
265
+ );
266
+
267
+ // ghost_inky.scad: Cyan Flanker Ghost ("Inky") - diameter 1.16
268
+ writeFile(
269
+ "scad/ghost_inky.scad",
270
+ `
271
+ $fn = 36;
272
+ $asa = 45;
273
+
274
+ anim_data = [
275
+ ["Float", [
276
+ ["GhostBody", [
277
+ [0.00, [0, 0, 0], [0, 0, 0.00]],
278
+ [0.35, [0, 0, 0], [0, 0, 0.06]],
279
+ [0.70, [0, 0, 0], [0, 0, 0.00]]
280
+ ]],
281
+ ["GhostEyes", [
282
+ [0.00, [0, 0, 0]],
283
+ [0.35, [0, 0, 6]],
284
+ [0.70, [0, 0, 0]]
285
+ ]]
286
+ ]]
287
+ ];
288
+
289
+ armature(animations=anim_data) {
290
+ bone(name="GhostRoot", t=[0, 0, 0], r=[0, 0, 0]) {
291
+ bone(name="GhostBody", t=[0, 0, 0], r=[0, 0, 0]) {
292
+ color([0.05, 0.88, 0.98], roughness=0.25, metalness=0.08, emissive=[0.02, 0.38, 0.45], emissiveIntensity=1.5, $asa=45) {
293
+ translate([0, 0, 0.22])
294
+ sphere(r=0.56);
295
+ translate([0, 0, -0.16])
296
+ cylinder(r=0.56, h=0.38, center=false);
297
+ for (i = [0:3]) {
298
+ rotate([0, 0, i * 90 + 45])
299
+ translate([0.35, 0.0, -0.24])
300
+ cylinder(r1=0.16, r2=0.20, h=0.22, center=true, $fn=18);
301
+ }
302
+ }
303
+
304
+ bone(name="GhostEyes", t=[0, 0, 0], r=[0, 0, 0]) {
305
+ color([0.96, 0.96, 1.0], roughness=0.15, metalness=0.0, $asa=35) {
306
+ translate([0.38, 0.20, 0.26])
307
+ scale([0.22, 0.18, 0.24])
308
+ sphere(r=1.0);
309
+ translate([0.38, -0.20, 0.26])
310
+ scale([0.22, 0.18, 0.24])
311
+ sphere(r=1.0);
312
+ }
313
+ color([0.1, 0.25, 0.9], roughness=0.1, metalness=0.2, emissive=[0.05, 0.15, 0.6], emissiveIntensity=1.5) {
314
+ translate([0.54, 0.20, 0.26])
315
+ scale([0.11, 0.11, 0.14])
316
+ sphere(r=1.0);
317
+ translate([0.54, -0.20, 0.26])
318
+ scale([0.11, 0.11, 0.14])
319
+ sphere(r=1.0);
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ `,
326
+ );
327
+
328
+ // ghost_clyde.scad: Orange Patrol Ghost ("Clyde") - diameter 1.16
329
+ writeFile(
330
+ "scad/ghost_clyde.scad",
331
+ `
332
+ $fn = 36;
333
+ $asa = 45;
334
+
335
+ anim_data = [
336
+ ["Float", [
337
+ ["GhostBody", [
338
+ [0.00, [0, 0, 0], [0, 0, 0.00]],
339
+ [0.35, [0, 0, 0], [0, 0, 0.06]],
340
+ [0.70, [0, 0, 0], [0, 0, 0.00]]
341
+ ]],
342
+ ["GhostEyes", [
343
+ [0.00, [0, 0, 0]],
344
+ [0.35, [0, 0, 6]],
345
+ [0.70, [0, 0, 0]]
346
+ ]]
347
+ ]]
348
+ ];
349
+
350
+ armature(animations=anim_data) {
351
+ bone(name="GhostRoot", t=[0, 0, 0], r=[0, 0, 0]) {
352
+ bone(name="GhostBody", t=[0, 0, 0], r=[0, 0, 0]) {
353
+ color([1.0, 0.62, 0.05], roughness=0.25, metalness=0.08, emissive=[0.42, 0.22, 0.02], emissiveIntensity=1.5, $asa=45) {
354
+ translate([0, 0, 0.22])
355
+ sphere(r=0.56);
356
+ translate([0, 0, -0.16])
357
+ cylinder(r=0.56, h=0.38, center=false);
358
+ for (i = [0:3]) {
359
+ rotate([0, 0, i * 90 + 45])
360
+ translate([0.35, 0.0, -0.24])
361
+ cylinder(r1=0.16, r2=0.20, h=0.22, center=true, $fn=18);
362
+ }
363
+ }
364
+
365
+ bone(name="GhostEyes", t=[0, 0, 0], r=[0, 0, 0]) {
366
+ color([0.96, 0.96, 1.0], roughness=0.15, metalness=0.0, $asa=35) {
367
+ translate([0.38, 0.20, 0.26])
368
+ scale([0.22, 0.18, 0.24])
369
+ sphere(r=1.0);
370
+ translate([0.38, -0.20, 0.26])
371
+ scale([0.22, 0.18, 0.24])
372
+ sphere(r=1.0);
373
+ }
374
+ color([0.1, 0.25, 0.9], roughness=0.1, metalness=0.2, emissive=[0.05, 0.15, 0.6], emissiveIntensity=1.5) {
375
+ translate([0.54, 0.20, 0.26])
376
+ scale([0.11, 0.11, 0.14])
377
+ sphere(r=1.0);
378
+ translate([0.54, -0.20, 0.26])
379
+ scale([0.11, 0.11, 0.14])
380
+ sphere(r=1.0);
381
+ }
382
+ }
383
+ }
384
+ }
385
+ }
386
+ `,
387
+ );
388
+
389
+ // ghost_frightened.scad: Vulnerable Electric Blue Ghost
390
+ writeFile(
391
+ "scad/ghost_frightened.scad",
392
+ `
393
+ $fn = 36;
394
+ $asa = 45;
395
+
396
+ anim_data = [
397
+ ["Panic", [
398
+ ["PanicBody", [
399
+ [0.00, [0, 0, -5], [0, 0, 0.00]],
400
+ [0.15, [0, 0, 5], [0, 0, 0.04]],
401
+ [0.30, [0, 0, -5], [0, 0, 0.00]]
402
+ ]]
403
+ ]]
404
+ ];
405
+
406
+ armature(animations=anim_data) {
407
+ bone(name="PanicRoot", t=[0, 0, 0], r=[0, 0, 0]) {
408
+ bone(name="PanicBody", t=[0, 0, 0], r=[0, 0, 0]) {
409
+ color([0.12, 0.22, 0.85], roughness=0.25, metalness=0.08, emissive=[0.10, 0.25, 0.90], emissiveIntensity=2.2, $asa=45) {
410
+ translate([0, 0, 0.22])
411
+ sphere(r=0.56);
412
+ translate([0, 0, -0.16])
413
+ cylinder(r=0.56, h=0.38, center=false);
414
+ for (i = [0:3]) {
415
+ rotate([0, 0, i * 90 + 45])
416
+ translate([0.35, 0.0, -0.24])
417
+ cylinder(r1=0.16, r2=0.20, h=0.22, center=true, $fn=18);
418
+ }
419
+ }
420
+
421
+ color([1.0, 0.92, 0.2], roughness=0.1, metalness=0.0, emissive=[1.0, 0.92, 0.2], emissiveIntensity=3.0) {
422
+ translate([0.50, 0.16, 0.26])
423
+ sphere(r=0.085);
424
+ translate([0.50, -0.16, 0.26])
425
+ sphere(r=0.085);
426
+ }
427
+
428
+ color([0.85, 0.95, 1.0], roughness=0.1, metalness=0.0, emissive=[0.85, 0.95, 1.0], emissiveIntensity=3.0) {
429
+ translate([0.52, 0.0, 0.08])
430
+ cube([0.06, 0.36, 0.05], center=true);
431
+ translate([0.52, 0.12, 0.04])
432
+ cube([0.06, 0.08, 0.08], center=true);
433
+ translate([0.52, -0.12, 0.04])
434
+ cube([0.06, 0.08, 0.08], center=true);
435
+ }
436
+ }
437
+ }
438
+ }
439
+ `,
440
+ );
441
+
442
+ // ghost_flash.scad: Flashing White Warning Ghost
443
+ writeFile(
444
+ "scad/ghost_flash.scad",
445
+ `
446
+ $fn = 36;
447
+ $asa = 45;
448
+
449
+ anim_data = [
450
+ ["Panic", [
451
+ ["FlashBody", [
452
+ [0.00, [0, 0, -5], [0, 0, 0.00]],
453
+ [0.15, [0, 0, 5], [0, 0, 0.04]],
454
+ [0.30, [0, 0, -5], [0, 0, 0.00]]
455
+ ]]
456
+ ]]
457
+ ];
458
+
459
+ armature(animations=anim_data) {
460
+ bone(name="FlashRoot", t=[0, 0, 0], r=[0, 0, 0]) {
461
+ bone(name="FlashBody", t=[0, 0, 0], r=[0, 0, 0]) {
462
+ color([0.95, 0.98, 1.0], roughness=0.2, metalness=0.08, emissive=[0.85, 0.92, 1.0], emissiveIntensity=2.6, $asa=45) {
463
+ translate([0, 0, 0.22])
464
+ sphere(r=0.56);
465
+ translate([0, 0, -0.16])
466
+ cylinder(r=0.56, h=0.38, center=false);
467
+ for (i = [0:3]) {
468
+ rotate([0, 0, i * 90 + 45])
469
+ translate([0.35, 0.0, -0.24])
470
+ cylinder(r1=0.16, r2=0.20, h=0.22, center=true, $fn=18);
471
+ }
472
+ }
473
+
474
+ color([1.0, 0.15, 0.15], roughness=0.1, metalness=0.0, emissive=[1.0, 0.1, 0.1], emissiveIntensity=3.2) {
475
+ translate([0.50, 0.16, 0.26])
476
+ sphere(r=0.085);
477
+ translate([0.50, -0.16, 0.26])
478
+ sphere(r=0.085);
479
+ }
480
+
481
+ color([1.0, 0.15, 0.15], roughness=0.1, metalness=0.0, emissive=[1.0, 0.1, 0.1], emissiveIntensity=3.2) {
482
+ translate([0.52, 0.0, 0.08])
483
+ cube([0.06, 0.36, 0.05], center=true);
484
+ translate([0.52, 0.12, 0.04])
485
+ cube([0.06, 0.08, 0.08], center=true);
486
+ translate([0.52, -0.12, 0.04])
487
+ cube([0.06, 0.08, 0.08], center=true);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ `,
493
+ );
494
+
495
+ // ghost_eyes.scad: Eaten Ghost Floating Eyes
496
+ writeFile(
497
+ "scad/ghost_eyes.scad",
498
+ `
499
+ $fn = 28;
500
+ $asa = 35;
501
+
502
+ anim_data = [
503
+ ["Look", [
504
+ ["EyesRoot", [
505
+ [0.00, [0, 0, 0], [0, 0, 0.00]],
506
+ [0.25, [0, 0, 0], [0, 0, 0.05]],
507
+ [0.50, [0, 0, 0], [0, 0, 0.00]]
508
+ ]]
509
+ ]]
510
+ ];
511
+
512
+ armature(animations=anim_data) {
513
+ bone(name="EyesRoot", t=[0, 0, 0], r=[0, 0, 0]) {
514
+ color([0.96, 0.96, 1.0], roughness=0.15, metalness=0.0, $asa=35) {
515
+ translate([0.22, 0.20, 0.26])
516
+ scale([0.22, 0.18, 0.24])
517
+ sphere(r=1.0);
518
+ translate([0.22, -0.20, 0.26])
519
+ scale([0.22, 0.18, 0.24])
520
+ sphere(r=1.0);
521
+ }
522
+ color([0.1, 0.25, 0.95], roughness=0.1, metalness=0.2, emissive=[0.1, 0.35, 1.0], emissiveIntensity=2.0) {
523
+ translate([0.40, 0.20, 0.26])
524
+ scale([0.11, 0.11, 0.14])
525
+ sphere(r=1.0);
526
+ translate([0.40, -0.20, 0.26])
527
+ scale([0.11, 0.11, 0.14])
528
+ sphere(r=1.0);
529
+ }
530
+ }
531
+ }
532
+ `,
533
+ );
534
+
535
+ // gate.scad: Cyber Energy Barrier replacing the awkward flat pink stripe
536
+ writeFile(
537
+ "scad/gate.scad",
538
+ `
539
+ $fn = 24;
540
+ $asa = 45;
541
+
542
+ // Obsidian and chrome side mounting brackets
543
+ color([0.08, 0.11, 0.18], roughness=0.3, metalness=0.85, $asa=35) {
544
+ translate([-0.54, 0, 0.15])
545
+ cube([0.12, 0.22, 0.30], center=true);
546
+ translate([0.54, 0, 0.15])
547
+ cube([0.12, 0.22, 0.30], center=true);
548
+ }
549
+
550
+ // Glowing cyan energy field beam
551
+ color([0.0, 0.85, 1.0], roughness=0.1, metalness=0.1, emissive=[0.0, 0.85, 1.0], emissiveIntensity=3.2) {
552
+ translate([0, 0, 0.18])
553
+ cube([1.04, 0.06, 0.10], center=true);
554
+ translate([0, 0, 0.10])
555
+ cube([0.96, 0.04, 0.04], center=true);
556
+ }
557
+ `,
558
+ );
559
+
560
+ // pellet.scad: Food pellet
561
+ writeFile(
562
+ "scad/pellet.scad",
563
+ `
564
+ $fn = 20;
565
+ $asa = 45;
566
+
567
+ anim_data = [
568
+ ["Spin", [
569
+ ["Gem", [
570
+ [0.0, [0, 0, 0], [0, 0, 0]],
571
+ [0.5, [0, 0, 90], [0, 0, 0]],
572
+ [1.0, [0, 0, 180], [0, 0, 0]],
573
+ [1.5, [0, 0, 270], [0, 0, 0]],
574
+ [2.0, [0, 0, 360], [0, 0, 0]]
575
+ ]]
576
+ ]]
577
+ ];
578
+
579
+ armature(animations=anim_data) {
580
+ bone(name="Gem", t=[0, 0, 0], r=[0, 0, 0]) {
581
+ color([1.0, 0.88, 0.3], roughness=0.1, metalness=0.1, emissive=[1.0, 0.80, 0.15], emissiveIntensity=2.8, $asa=45) {
582
+ sphere(r=0.12);
583
+ cylinder(r=0.14, h=0.05, center=true);
584
+ }
585
+ }
586
+ }
587
+ `,
588
+ );
589
+
590
+ // power_pellet.scad: Energizer with gimbal rings
591
+ writeFile(
592
+ "scad/power_pellet.scad",
593
+ `
594
+ $fn = 28;
595
+ $asa = 45;
596
+
597
+ anim_data = [
598
+ ["PowerSpin", [
599
+ ["Core", [
600
+ [0.0, [0, 0, 0], [0, 0, 0.00]],
601
+ [0.5, [0, 0, 0], [0, 0, 0.04]],
602
+ [1.0, [0, 0, 0], [0, 0, 0.00]]
603
+ ]],
604
+ ["OuterRing", [
605
+ [0.0, [0, 0, 0], [0, 0, 0]],
606
+ [0.5, [0, 90, 0], [0, 0, 0]],
607
+ [1.0, [0, 180, 0], [0, 0, 0]],
608
+ [1.5, [0, 270, 0], [0, 0, 0]],
609
+ [2.0, [0, 360, 0], [0, 0, 0]]
610
+ ]],
611
+ ["InnerRing", [
612
+ [0.0, [0, 0, 0], [0, 0, 0]],
613
+ [0.5, [90, 0, 0], [0, 0, 0]],
614
+ [1.0, [180, 0, 0], [0, 0, 0]],
615
+ [1.5, [270, 0, 0], [0, 0, 0]],
616
+ [2.0, [360, 0, 0], [0, 0, 0]]
617
+ ]]
618
+ ]]
619
+ ];
620
+
621
+ armature(animations=anim_data) {
622
+ bone(name="PowerRoot", t=[0, 0, 0], r=[0, 0, 0]) {
623
+ bone(name="Core", t=[0, 0, 0], r=[0, 0, 0]) {
624
+ color([1.0, 1.0, 0.7], roughness=0.08, metalness=0.05, emissive=[1.0, 0.95, 0.4], emissiveIntensity=4.5, $asa=45) {
625
+ sphere(r=0.25);
626
+ }
627
+ }
628
+
629
+ bone(name="OuterRing", t=[0, 0, 0], r=[0, 0, 0]) {
630
+ color([0.2, 0.8, 1.0], roughness=0.2, metalness=0.8, emissive=[0.0, 0.6, 1.0], emissiveIntensity=2.0) {
631
+ difference() {
632
+ cylinder(r=0.38, h=0.04, center=true);
633
+ cylinder(r=0.32, h=0.06, center=true);
634
+ }
635
+ }
636
+ }
637
+
638
+ bone(name="InnerRing", t=[0, 0, 0], r=[0, 0, 0]) {
639
+ color([1.0, 0.4, 0.1], roughness=0.2, metalness=0.8, emissive=[1.0, 0.3, 0.0], emissiveIntensity=2.0) {
640
+ difference() {
641
+ cylinder(r=0.30, h=0.03, center=true);
642
+ cylinder(r=0.25, h=0.05, center=true);
643
+ }
644
+ }
645
+ }
646
+ }
647
+ }
648
+ `,
649
+ );
650
+
651
+ // wall.scad: Cyber Maze Wall block (1.16 x 1.16 x 0.90)
652
+ writeFile(
653
+ "scad/wall.scad",
654
+ `
655
+ $fn = 24;
656
+ $asa = 35;
657
+
658
+ color([0.08, 0.11, 0.18], roughness=0.3, metalness=0.85, $asa=35) {
659
+ translate([0, 0, 0.44])
660
+ cube([1.16, 1.16, 0.88], center=true);
661
+ }
662
+
663
+ color([0.0, 0.85, 1.0], roughness=0.1, metalness=0.1, emissive=[0.0, 0.85, 1.0], emissiveIntensity=3.5) {
664
+ translate([0, 0, 0.89])
665
+ difference() {
666
+ cube([1.18, 1.18, 0.05], center=true);
667
+ cube([0.98, 0.98, 0.08], center=true);
668
+ }
669
+ translate([0, 0, 0.88])
670
+ cylinder(r=0.16, h=0.06, center=true);
671
+ }
672
+
673
+ color([0.85, 0.90, 0.95], roughness=0.1, metalness=0.95) {
674
+ for (dx = [-0.52, 0.52]) {
675
+ for (dy = [-0.52, 0.52]) {
676
+ translate([dx, dy, 0.44])
677
+ cylinder(r=0.045, h=0.90, center=true);
678
+ }
679
+ }
680
+ }
681
+ `,
682
+ );
683
+
684
+ // cherry.scad: Bonus Arcade Fruit (diameter 0.45, shiny cherries with stems & leaf)
685
+ writeFile(
686
+ "scad/cherry.scad",
687
+ `
688
+ $fn = 28;
689
+ $asa = 45;
690
+
691
+ anim_data = [
692
+ ["Float", [
693
+ ["CherryRoot", [
694
+ [0.0, [0, 0, 0], [0, 0, 0.0]],
695
+ [0.4, [0, 0, 15], [0, 0, 0.06]],
696
+ [0.8, [0, 0, 0], [0, 0, 0.0]]
697
+ ]]
698
+ ]]
699
+ ];
700
+
701
+ armature(animations=anim_data) {
702
+ bone(name="CherryRoot", t=[0, 0, 0], r=[0, 0, 0]) {
703
+ color([0.95, 0.05, 0.15], roughness=0.12, metalness=0.05, specularIntensity=1.5, emissive=[0.30, 0.0, 0.05], emissiveIntensity=1.2, $asa=45) {
704
+ translate([-0.16, 0.0, 0.18])
705
+ sphere(r=0.18);
706
+ translate([0.16, 0.06, 0.18])
707
+ sphere(r=0.18);
708
+ }
709
+ color([0.45, 0.28, 0.12], roughness=0.6, metalness=0.0) {
710
+ translate([-0.08, 0.01, 0.36])
711
+ rotate([0, 20, 0])
712
+ cylinder(r=0.022, h=0.28, center=true);
713
+ translate([0.08, 0.04, 0.36])
714
+ rotate([0, -20, 0])
715
+ cylinder(r=0.022, h=0.28, center=true);
716
+ }
717
+ color([0.1, 0.85, 0.2], roughness=0.3, metalness=0.0, emissive=[0.02, 0.3, 0.05], emissiveIntensity=1.5, $asa=35) {
718
+ translate([0.06, 0.04, 0.48])
719
+ rotate([15, 30, 25])
720
+ scale([0.14, 0.07, 0.03])
721
+ sphere(r=1.0);
722
+ }
723
+ }
724
+ }
725
+ `,
726
+ );
727
+
728
+ // =========================================================================
729
+ // 4. Rust Bevy Application Logic (src/main.rs)
730
+ // =========================================================================
731
+ writeFile(
732
+ "src/main.rs",
733
+ `
734
+ use bevy::audio::Volume;
735
+ use bevy::prelude::*;
736
+ use rand::Rng;
737
+ use std::f32::consts::PI;
738
+ use std::fs;
739
+ use std::path::Path;
740
+
741
+ const SAMPLE_RATE: u32 = 44100;
742
+
743
+ // =========================================================================
744
+ // Procedural Audio Synthesizer (Generates 16-bit PCM RIFF WAV in memory)
745
+ // =========================================================================
746
+
747
+ fn write_wav_file(path: &str, samples: &[i16], sample_rate: u32) {
748
+ let mut data = Vec::with_capacity(44 + samples.len() * 2);
749
+ // RIFF Header
750
+ data.extend_from_slice(b"RIFF");
751
+ let file_size = 36u32 + (samples.len() * 2) as u32;
752
+ data.extend_from_slice(&file_size.to_le_bytes());
753
+ data.extend_from_slice(b"WAVE");
754
+
755
+ // fmt Chunk
756
+ data.extend_from_slice(b"fmt ");
757
+ data.extend_from_slice(&16u32.to_le_bytes());
758
+ data.extend_from_slice(&1u16.to_le_bytes());
759
+ data.extend_from_slice(&1u16.to_le_bytes());
760
+ data.extend_from_slice(&sample_rate.to_le_bytes());
761
+ let byte_rate = sample_rate * 2;
762
+ data.extend_from_slice(&byte_rate.to_le_bytes());
763
+ data.extend_from_slice(&2u16.to_le_bytes());
764
+ data.extend_from_slice(&16u16.to_le_bytes());
765
+
766
+ // data Chunk
767
+ data.extend_from_slice(b"data");
768
+ let data_len = (samples.len() * 2) as u32;
769
+ data.extend_from_slice(&data_len.to_le_bytes());
770
+ for &sample in samples {
771
+ data.extend_from_slice(&sample.to_le_bytes());
772
+ }
773
+
774
+ if let Err(e) = fs::write(path, &data) {
775
+ eprintln!("Failed writing procedural audio {}: {}", path, e);
776
+ }
777
+ }
778
+
779
+ fn sine(phase: f32) -> f32 {
780
+ phase.sin()
781
+ }
782
+
783
+ fn square(phase: f32, duty: f32) -> f32 {
784
+ let t = (phase / (2.0 * PI)).fract();
785
+ let norm = if t < 0.0 { t + 1.0 } else { t };
786
+ if norm < duty { 1.0 } else { -1.0 }
787
+ }
788
+
789
+ fn triangle(phase: f32) -> f32 {
790
+ let t = (phase / (2.0 * PI)).fract();
791
+ let norm = if t < 0.0 { t + 1.0 } else { t };
792
+ 2.0 * (2.0 * (norm - (norm + 0.5).floor())).abs() - 1.0
793
+ }
794
+
795
+ fn generate_procedural_sounds() {
796
+ let dir = Path::new("assets/audio");
797
+ let _ = fs::create_dir_all(dir);
798
+
799
+ // 1. Waka 0 (Opening Chomp Sweep 220Hz -> 480Hz)
800
+ {
801
+ let dur = 0.085;
802
+ let total = (dur * SAMPLE_RATE as f32) as usize;
803
+ let mut samples = Vec::with_capacity(total);
804
+ let mut phase = 0.0f32;
805
+ for i in 0..total {
806
+ let t = i as f32 / SAMPLE_RATE as f32;
807
+ let frac = t / dur;
808
+ let freq = 220.0 + (480.0 - 220.0) * frac;
809
+ phase += 2.0 * PI * freq / SAMPLE_RATE as f32;
810
+ let env = (frac / 0.15).min(1.0) * (1.0 - frac).max(0.0);
811
+ let val = 0.5 * square(phase, 0.45) + 0.5 * triangle(phase);
812
+ samples.push((val * env * 14000.0) as i16);
813
+ }
814
+ write_wav_file("assets/audio/waka_0.wav", &samples, SAMPLE_RATE);
815
+ }
816
+
817
+ // 2. Waka 1 (Closing Chomp Sweep 480Hz -> 190Hz)
818
+ {
819
+ let dur = 0.085;
820
+ let total = (dur * SAMPLE_RATE as f32) as usize;
821
+ let mut samples = Vec::with_capacity(total);
822
+ let mut phase = 0.0f32;
823
+ for i in 0..total {
824
+ let t = i as f32 / SAMPLE_RATE as f32;
825
+ let frac = t / dur;
826
+ let freq = 480.0 - (480.0 - 190.0) * frac;
827
+ phase += 2.0 * PI * freq / SAMPLE_RATE as f32;
828
+ let env = (frac / 0.15).min(1.0) * (1.0 - frac).max(0.0);
829
+ let val = 0.5 * square(phase, 0.45) + 0.5 * triangle(phase);
830
+ samples.push((val * env * 14000.0) as i16);
831
+ }
832
+ write_wav_file("assets/audio/waka_1.wav", &samples, SAMPLE_RATE);
833
+ }
834
+
835
+ // 3. Power Pellet (Low Energetic Hum)
836
+ {
837
+ let dur = 0.25;
838
+ let total = (dur * SAMPLE_RATE as f32) as usize;
839
+ let mut samples = Vec::with_capacity(total);
840
+ let mut phase = 0.0f32;
841
+ for i in 0..total {
842
+ let t = i as f32 / SAMPLE_RATE as f32;
843
+ let freq = 80.0 + 40.0 * (2.0 * PI * 12.0 * t).sin();
844
+ phase += 2.0 * PI * freq / SAMPLE_RATE as f32;
845
+ let val = triangle(phase);
846
+ samples.push((val * 16000.0) as i16);
847
+ }
848
+ write_wav_file("assets/audio/power_pellet.wav", &samples, SAMPLE_RATE);
849
+ }
850
+
851
+ // 4. Eat Ghost (Upward Staccato Fanfare Arpeggio)
852
+ {
853
+ let notes = [523.25, 659.25, 783.99, 1046.50, 1318.51, 1567.98];
854
+ let note_dur = 0.07;
855
+ let total = (note_dur * notes.len() as f32 * SAMPLE_RATE as f32) as usize;
856
+ let mut samples = Vec::with_capacity(total);
857
+ for &freq in &notes {
858
+ let mut phase = 0.0f32;
859
+ let note_samples = (note_dur * SAMPLE_RATE as f32) as usize;
860
+ for i in 0..note_samples {
861
+ let frac = i as f32 / note_samples as f32;
862
+ phase += 2.0 * PI * freq / SAMPLE_RATE as f32;
863
+ let env = (1.0 - frac).powf(1.4);
864
+ let val = 0.6 * square(phase, 0.35) + 0.4 * sine(phase);
865
+ samples.push((val * env * 18000.0) as i16);
866
+ }
867
+ }
868
+ write_wav_file("assets/audio/eat_ghost.wav", &samples, SAMPLE_RATE);
869
+ }
870
+
871
+ // 5. Death Sound (Classic Descending Step Sweep)
872
+ {
873
+ let steps = 14;
874
+ let step_dur = 0.08;
875
+ let mut samples = Vec::new();
876
+ for s in 0..steps {
877
+ let freq = 820.0 * (0.86f32).powi(s as i32);
878
+ let mut phase = 0.0f32;
879
+ let count = (step_dur * SAMPLE_RATE as f32) as usize;
880
+ for i in 0..count {
881
+ let t = i as f32 / count as f32;
882
+ let pitch = freq * (1.0 - t * 0.15);
883
+ phase += 2.0 * PI * pitch / SAMPLE_RATE as f32;
884
+ let env = 1.0 - t * 0.5;
885
+ let val = square(phase, 0.5);
886
+ samples.push((val * env * 15000.0) as i16);
887
+ }
888
+ }
889
+ write_wav_file("assets/audio/death.wav", &samples, SAMPLE_RATE);
890
+ }
891
+
892
+ // 6. Bonus Fruit Eaten (Two-Tone Bright Chime)
893
+ {
894
+ let freqs = [880.0, 1320.0];
895
+ let durs = [0.12, 0.20];
896
+ let mut samples = Vec::new();
897
+ for (&freq, &dur) in freqs.iter().zip(durs.iter()) {
898
+ let count = (dur * SAMPLE_RATE as f32) as usize;
899
+ let mut phase = 0.0f32;
900
+ for i in 0..count {
901
+ let t = i as f32 / count as f32;
902
+ phase += 2.0 * PI * freq / SAMPLE_RATE as f32;
903
+ let env = (1.0 - t).powf(1.2);
904
+ let val = 0.7 * sine(phase) + 0.3 * sine(phase * 2.0);
905
+ samples.push((val * env * 19000.0) as i16);
906
+ }
907
+ }
908
+ write_wav_file("assets/audio/fruit.wav", &samples, SAMPLE_RATE);
909
+ }
910
+
911
+ // 7. Intro Melody Fanfare
912
+ {
913
+ let melody = [
914
+ (493.88, 0.12), (987.77, 0.12), (739.99, 0.12), (622.25, 0.12),
915
+ (987.77, 0.12), (739.99, 0.16), (622.25, 0.22),
916
+ (523.25, 0.12), (1046.50, 0.12), (783.99, 0.12), (659.25, 0.12),
917
+ (1046.50, 0.12), (783.99, 0.16), (659.25, 0.22),
918
+ (493.88, 0.12), (987.77, 0.12), (739.99, 0.12), (622.25, 0.12),
919
+ (987.77, 0.12), (739.99, 0.16), (622.25, 0.22),
920
+ (622.25, 0.08), (659.25, 0.08), (698.46, 0.08), (739.99, 0.08),
921
+ (783.99, 0.08), (830.61, 0.08), (880.00, 0.08), (987.77, 0.35),
922
+ ];
923
+ let mut samples = Vec::new();
924
+ for (freq, dur) in melody {
925
+ let count = (dur * SAMPLE_RATE as f32) as usize;
926
+ let mut phase = 0.0f32;
927
+ for i in 0..count {
928
+ let frac = i as f32 / count as f32;
929
+ phase += 2.0 * PI * freq / SAMPLE_RATE as f32;
930
+ let env = (1.0 - frac * 0.25).min(1.0);
931
+ let val = square(phase, 0.25);
932
+ samples.push((val * env * 14000.0) as i16);
933
+ }
934
+ }
935
+ write_wav_file("assets/audio/game_start.wav", &samples, SAMPLE_RATE);
936
+ }
937
+ }
938
+
939
+ // =========================================================================
940
+ // Maze Grid Definition & Navigation Constants
941
+ // Row 7 column 10 is clear corridor ' ' for Blinky to spawn cleanly outside house!
942
+ // Row 8 column 10 is the Cyber Gate 'D'.
943
+ // =========================================================================
944
+
945
+ const MAP_WIDTH: usize = 21;
946
+ const MAP_HEIGHT: usize = 21;
947
+ const CELL_SIZE: f32 = 1.2;
948
+
949
+ const MAZE_TILES: [&str; 21] = [
950
+ "WWWWWWWWWWWWWWWWWWWWW", // 0
951
+ "WO........W........OW", // 1
952
+ "W.WWW.WWW.W.WWW.WWW.W", // 2
953
+ "W.WWW.WWW.W.WWW.WWW.W", // 3
954
+ "W...................W", // 4
955
+ "W.WWW.W.WWWWW.W.WWW.W", // 5
956
+ "W.....W...W...W.....W", // 6
957
+ "WWWWW.WWW WWW.WWWWW", // 7: Open corridor across columns 9, 10, 11 (Blinky starts at 7, 10!)
958
+ " W.W WWDWW W.W ", // 8: Top of ghost house; Cyber Gate 'D' at index 10
959
+ "WWWWW.W GGGGG W.WWWWW", // 9: Inside Ghost House
960
+ " . GGGGG . ", // 10: Tunnel row & Ghost House center
961
+ "WWWWW.W GGGGG W.WWWWW", // 11: Inside Ghost House
962
+ " W.W WWWWW W.W ", // 12: Bottom wall of Ghost House
963
+ "WWWWW.W W.WWWWW", // 13: Corridor beneath Ghost House (Cherry spawns at 13, 10!)
964
+ "W.........W.........W", // 14
965
+ "W.WWW.WWW.W.WWW.WWW.W", // 15
966
+ "WO..W.....P.....W..OW", // 16: Pacman start at (16, 10)
967
+ "WWW.W.W.WWWWW.W.W.WWW", // 17
968
+ "W.....W...W...W.....W", // 18
969
+ "W.WWWWWWW.W.WWWWWWW.W", // 19
970
+ "WWWWWWWWWWWWWWWWWWWWW", // 20
971
+ ];
972
+
973
+ #[derive(Clone, Copy, PartialEq, Eq, Debug)]
974
+ enum Direction {
975
+ None,
976
+ Up,
977
+ Down,
978
+ Left,
979
+ Right,
980
+ }
981
+
982
+ impl Direction {
983
+ fn offset(&self) -> (i32, i32) {
984
+ match self {
985
+ Direction::None => (0, 0),
986
+ Direction::Up => (-1, 0),
987
+ Direction::Down => (1, 0),
988
+ Direction::Left => (0, -1),
989
+ Direction::Right => (0, 1),
990
+ }
991
+ }
992
+
993
+ fn opposite(&self) -> Direction {
994
+ match self {
995
+ Direction::Up => Direction::Down,
996
+ Direction::Down => Direction::Up,
997
+ Direction::Left => Direction::Right,
998
+ Direction::Right => Direction::Left,
999
+ Direction::None => Direction::None,
1000
+ }
1001
+ }
1002
+
1003
+ fn to_rotation_y(&self) -> f32 {
1004
+ match self {
1005
+ Direction::Right => 0.0,
1006
+ Direction::Down => -PI * 0.5,
1007
+ Direction::Left => PI,
1008
+ Direction::Up => PI * 0.5,
1009
+ Direction::None => 0.0,
1010
+ }
1011
+ }
1012
+ }
1013
+
1014
+ fn grid_to_world(row: usize, col: usize) -> Vec3 {
1015
+ let half_w = (MAP_WIDTH as f32 - 1.0) / 2.0;
1016
+ let half_h = (MAP_HEIGHT as f32 - 1.0) / 2.0;
1017
+ Vec3::new(
1018
+ (col as f32 - half_w) * CELL_SIZE,
1019
+ 0.0,
1020
+ (row as f32 - half_h) * CELL_SIZE,
1021
+ )
1022
+ }
1023
+
1024
+ fn is_wall(row: usize, col: usize) -> bool {
1025
+ if row >= MAP_HEIGHT || col >= MAP_WIDTH {
1026
+ return true;
1027
+ }
1028
+ let ch = MAZE_TILES[row].as_bytes()[col];
1029
+ ch == b'W'
1030
+ }
1031
+
1032
+ // =========================================================================
1033
+ // Game Components & Resources
1034
+ // =========================================================================
1035
+
1036
+ #[derive(Component, Clone)]
1037
+ struct AutoPlaySceneAnimation {
1038
+ clip: Handle<AnimationClip>,
1039
+ }
1040
+
1041
+ #[derive(Component)]
1042
+ struct Pacman {
1043
+ grid_pos: (usize, usize),
1044
+ next_tile: (usize, usize),
1045
+ progress: f32,
1046
+ speed: f32,
1047
+ current_dir: Direction,
1048
+ queued_dir: Direction,
1049
+ chomp_flip: bool,
1050
+ }
1051
+
1052
+ #[derive(Clone, Copy, PartialEq, Eq)]
1053
+ enum GhostType {
1054
+ Blinky,
1055
+ Pinky,
1056
+ Inky,
1057
+ Clyde,
1058
+ }
1059
+
1060
+ #[derive(Clone, Copy, PartialEq, Eq)]
1061
+ enum GhostState {
1062
+ InHouse,
1063
+ Chase,
1064
+ Scatter,
1065
+ Frightened,
1066
+ Eaten,
1067
+ }
1068
+
1069
+ #[derive(Component)]
1070
+ struct Ghost {
1071
+ identity: GhostType,
1072
+ state: GhostState,
1073
+ grid_pos: (usize, usize),
1074
+ next_tile: (usize, usize),
1075
+ progress: f32,
1076
+ current_dir: Direction,
1077
+ home_corner: (usize, usize),
1078
+ house_timer: f32,
1079
+ }
1080
+
1081
+ #[derive(Component)]
1082
+ struct GhostLight {
1083
+ base_color: Color,
1084
+ }
1085
+
1086
+ #[derive(Component)]
1087
+ struct NormalVisual;
1088
+
1089
+ #[derive(Component)]
1090
+ struct FrightenedVisual;
1091
+
1092
+ #[derive(Component)]
1093
+ struct FlashVisual;
1094
+
1095
+ #[derive(Component)]
1096
+ struct EyesVisual;
1097
+
1098
+ #[derive(Component)]
1099
+ struct Pellet {
1100
+ is_power: bool,
1101
+ seed: f32,
1102
+ }
1103
+
1104
+ #[derive(Component)]
1105
+ struct CherryFruit {
1106
+ timer: f32,
1107
+ }
1108
+
1109
+ #[derive(Component)]
1110
+ struct FloatingText {
1111
+ lifetime: f32,
1112
+ }
1113
+
1114
+ #[derive(Component)]
1115
+ struct ScoreText;
1116
+
1117
+ #[derive(Component)]
1118
+ struct LivesText;
1119
+
1120
+ #[derive(Component)]
1121
+ struct BannerText;
1122
+
1123
+ #[derive(Component)]
1124
+ struct MainCamera;
1125
+
1126
+ enum CameraMode {
1127
+ Isometric,
1128
+ TopDown,
1129
+ Follow,
1130
+ }
1131
+
1132
+ #[derive(Resource)]
1133
+ struct AudioLibrary {
1134
+ waka_0: Handle<AudioSource>,
1135
+ waka_1: Handle<AudioSource>,
1136
+ power_pellet: Handle<AudioSource>,
1137
+ eat_ghost: Handle<AudioSource>,
1138
+ death: Handle<AudioSource>,
1139
+ fruit: Handle<AudioSource>,
1140
+ game_start: Handle<AudioSource>,
1141
+ }
1142
+
1143
+ #[derive(PartialEq, Eq)]
1144
+ enum GamePhase {
1145
+ Ready,
1146
+ Playing,
1147
+ PacmanDying,
1148
+ GameOver,
1149
+ Victory,
1150
+ }
1151
+
1152
+ #[derive(Resource)]
1153
+ struct GameManager {
1154
+ score: u32,
1155
+ high_score: u32,
1156
+ lives: u32,
1157
+ level: u32,
1158
+ pellets_remaining: usize,
1159
+ phase: GamePhase,
1160
+ phase_timer: f32,
1161
+ frightened_timer: f32,
1162
+ ghost_kill_streak: u32,
1163
+ scatter_cycle_timer: f32,
1164
+ is_scatter_mode: bool,
1165
+ camera_mode: CameraMode,
1166
+ fruit_respawn_timer: f32,
1167
+ }
1168
+
1169
+ // =========================================================================
1170
+ // Initialization Systems
1171
+ // =========================================================================
1172
+
1173
+ fn setup_game(
1174
+ mut commands: Commands,
1175
+ asset_server: Res<AssetServer>,
1176
+ mut materials: ResMut<Assets<StandardMaterial>>,
1177
+ mut meshes: ResMut<Assets<Mesh>>,
1178
+ ) {
1179
+ // 1. Audio Library
1180
+ let audio_lib = AudioLibrary {
1181
+ waka_0: asset_server.load("audio/waka_0.wav"),
1182
+ waka_1: asset_server.load("audio/waka_1.wav"),
1183
+ power_pellet: asset_server.load("audio/power_pellet.wav"),
1184
+ eat_ghost: asset_server.load("audio/eat_ghost.wav"),
1185
+ death: asset_server.load("audio/death.wav"),
1186
+ fruit: asset_server.load("audio/fruit.wav"),
1187
+ game_start: asset_server.load("audio/game_start.wav"),
1188
+ };
1189
+
1190
+ commands.spawn((
1191
+ AudioPlayer(audio_lib.game_start.clone()),
1192
+ PlaybackSettings::DESPAWN.with_volume(Volume::new(0.65)),
1193
+ ));
1194
+ commands.insert_resource(audio_lib);
1195
+
1196
+ // 2. Camera Setup
1197
+ commands.spawn((
1198
+ Camera3d::default(),
1199
+ Transform::from_xyz(0.0, 25.0, 15.0).looking_at(Vec3::new(0.0, 0.0, 0.8), Dir3::Y),
1200
+ MainCamera,
1201
+ ));
1202
+
1203
+ // 3. Directional Key Light & Ambient
1204
+ commands.spawn((
1205
+ DirectionalLight {
1206
+ illuminance: 2400.0,
1207
+ shadows_enabled: true,
1208
+ ..default()
1209
+ },
1210
+ Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -1.05, 0.4, 0.0)),
1211
+ ));
1212
+
1213
+ // 4. Labyrinth Floor (Deep Navy Reflective Grid)
1214
+ let floor_mat = materials.add(StandardMaterial {
1215
+ base_color: Color::srgb(0.02, 0.03, 0.06),
1216
+ perceptual_roughness: 0.25,
1217
+ metallic: 0.8,
1218
+ ..default()
1219
+ });
1220
+ commands.spawn((
1221
+ Mesh3d(meshes.add(Plane3d::default().mesh().size(32.0, 32.0))),
1222
+ MeshMaterial3d(floor_mat),
1223
+ Transform::from_xyz(0.0, -0.05, 0.0),
1224
+ ));
1225
+
1226
+ // 5. Build Maze Layout: Walls and Cyber Gate
1227
+ for (r, row_str) in MAZE_TILES.iter().enumerate() {
1228
+ for (c, &tile_byte) in row_str.as_bytes().iter().enumerate() {
1229
+ let world = grid_to_world(r, c);
1230
+
1231
+ match tile_byte {
1232
+ b'W' => {
1233
+ commands.spawn((
1234
+ SceneRoot(asset_server.load("models/wall.glb#Scene0")),
1235
+ Transform::from_xyz(world.x, 0.0, world.z),
1236
+ ));
1237
+ }
1238
+ b'D' => {
1239
+ // Cyber Gate energy barrier (replaces the awkward pink stripe!)
1240
+ commands.spawn((
1241
+ SceneRoot(asset_server.load("models/gate.glb#Scene0")),
1242
+ Transform::from_xyz(world.x, 0.0, world.z),
1243
+ ));
1244
+ }
1245
+ _ => {}
1246
+ }
1247
+ }
1248
+ }
1249
+
1250
+ // Spawn all collectible pellets
1251
+ let total_pellets = spawn_field_pellets(&mut commands, &asset_server);
1252
+
1253
+ // Spawn initial bonus Cherry beneath the Ghost House at (13, 10)
1254
+ spawn_bonus_cherry(&mut commands, &asset_server);
1255
+
1256
+ // 6. Spawn Pacman (scaled to 1.16 diameter, perfectly matching the 1.24 tunnel)
1257
+ // Equipped with warm dynamic yellow aura light matching classic arcade illumination
1258
+ let pacman_start = grid_to_world(16, 10);
1259
+ commands
1260
+ .spawn((
1261
+ Pacman {
1262
+ grid_pos: (16, 10),
1263
+ next_tile: (16, 10),
1264
+ progress: 1.0,
1265
+ speed: 3.2, // Tuned for comfortable, classic arcade pacing
1266
+ current_dir: Direction::None,
1267
+ queued_dir: Direction::None,
1268
+ chomp_flip: false,
1269
+ },
1270
+ AutoPlaySceneAnimation {
1271
+ clip: asset_server.load("models/packman.glb#Animation0"),
1272
+ },
1273
+ SceneRoot(asset_server.load("models/packman.glb#Scene0")),
1274
+ Transform::from_xyz(pacman_start.x, 0.50, pacman_start.z),
1275
+ ))
1276
+ .with_child((
1277
+ PointLight {
1278
+ color: Color::srgb(1.0, 0.9, 0.2),
1279
+ intensity: 75_000.0,
1280
+ range: 5.5,
1281
+ shadows_enabled: false,
1282
+ ..default()
1283
+ },
1284
+ Transform::from_xyz(0.0, 0.45, 0.0),
1285
+ ));
1286
+
1287
+ // 7. Spawn the 4 Iconic Ghosts (diameter 1.16) with individual aura lights
1288
+ // Blinky starts cleanly at (7, 10) in the open corridor directly outside the gate!
1289
+ spawn_ghost(&mut commands, &asset_server, GhostType::Blinky, (7, 10), GhostState::Scatter, 0.0);
1290
+ // Pinky, Inky, Clyde inside the Ghost House at row 10
1291
+ spawn_ghost(&mut commands, &asset_server, GhostType::Pinky, (10, 10), GhostState::InHouse, 2.0);
1292
+ spawn_ghost(&mut commands, &asset_server, GhostType::Inky, (10, 9), GhostState::InHouse, 4.0);
1293
+ spawn_ghost(&mut commands, &asset_server, GhostType::Clyde, (10, 11), GhostState::InHouse, 6.0);
1294
+
1295
+ // 8. Arcade HUD UI
1296
+ commands.spawn((
1297
+ Text::new("SCORE: 000000"),
1298
+ TextFont { font_size: 24.0, ..default() },
1299
+ TextColor(Color::srgb(1.0, 0.9, 0.2)),
1300
+ Node {
1301
+ position_type: PositionType::Absolute,
1302
+ top: Val::Px(16.0),
1303
+ left: Val::Px(24.0),
1304
+ ..default()
1305
+ },
1306
+ ScoreText,
1307
+ ));
1308
+
1309
+ commands.spawn((
1310
+ Text::new("LIVES: 3 | LEVEL: 1 | [C: CAM] [R: RESET]"),
1311
+ TextFont { font_size: 24.0, ..default() },
1312
+ TextColor(Color::srgb(0.2, 0.9, 1.0)),
1313
+ Node {
1314
+ position_type: PositionType::Absolute,
1315
+ top: Val::Px(16.0),
1316
+ right: Val::Px(24.0),
1317
+ ..default()
1318
+ },
1319
+ LivesText,
1320
+ ));
1321
+
1322
+ commands.spawn((
1323
+ Text::new("READY!"),
1324
+ TextFont { font_size: 40.0, ..default() },
1325
+ TextColor(Color::srgb(1.0, 0.85, 0.1)),
1326
+ Node {
1327
+ position_type: PositionType::Absolute,
1328
+ bottom: Val::Px(24.0),
1329
+ left: Val::Percent(38.0),
1330
+ ..default()
1331
+ },
1332
+ BannerText,
1333
+ ));
1334
+
1335
+ commands.insert_resource(GameManager {
1336
+ score: 0,
1337
+ high_score: 10000,
1338
+ lives: 3,
1339
+ level: 1,
1340
+ pellets_remaining: total_pellets,
1341
+ phase: GamePhase::Ready,
1342
+ phase_timer: 3.5,
1343
+ frightened_timer: 0.0,
1344
+ ghost_kill_streak: 200,
1345
+ scatter_cycle_timer: 7.0,
1346
+ is_scatter_mode: true,
1347
+ camera_mode: CameraMode::Isometric,
1348
+ fruit_respawn_timer: 0.0,
1349
+ });
1350
+ }
1351
+
1352
+ fn spawn_field_pellets(commands: &mut Commands, asset_server: &Res<AssetServer>) -> usize {
1353
+ let mut total_pellets = 0;
1354
+ for (r, row_str) in MAZE_TILES.iter().enumerate() {
1355
+ for (c, &tile_byte) in row_str.as_bytes().iter().enumerate() {
1356
+ let world = grid_to_world(r, c);
1357
+
1358
+ match tile_byte {
1359
+ b'.' => {
1360
+ commands.spawn((
1361
+ Pellet { is_power: false, seed: (r * 31 + c) as f32 },
1362
+ AutoPlaySceneAnimation {
1363
+ clip: asset_server.load("models/pellet.glb#Animation0"),
1364
+ },
1365
+ SceneRoot(asset_server.load("models/pellet.glb#Scene0")),
1366
+ Transform::from_xyz(world.x, 0.25, world.z),
1367
+ ));
1368
+ total_pellets += 1;
1369
+ }
1370
+ b'O' => {
1371
+ commands.spawn((
1372
+ Pellet { is_power: true, seed: (r * 31 + c) as f32 },
1373
+ AutoPlaySceneAnimation {
1374
+ clip: asset_server.load("models/power_pellet.glb#Animation0"),
1375
+ },
1376
+ SceneRoot(asset_server.load("models/power_pellet.glb#Scene0")),
1377
+ Transform::from_xyz(world.x, 0.35, world.z),
1378
+ ));
1379
+ total_pellets += 1;
1380
+ }
1381
+ _ => {}
1382
+ }
1383
+ }
1384
+ }
1385
+ total_pellets
1386
+ }
1387
+
1388
+ fn spawn_bonus_cherry(commands: &mut Commands, asset_server: &Res<AssetServer>) {
1389
+ let fruit_pos = grid_to_world(13, 10);
1390
+ commands.spawn((
1391
+ CherryFruit { timer: 20.0 },
1392
+ AutoPlaySceneAnimation {
1393
+ clip: asset_server.load("models/cherry.glb#Animation0"),
1394
+ },
1395
+ SceneRoot(asset_server.load("models/cherry.glb#Scene0")),
1396
+ Transform::from_xyz(fruit_pos.x, 0.38, fruit_pos.z),
1397
+ ));
1398
+ }
1399
+
1400
+ fn spawn_ghost(
1401
+ commands: &mut Commands,
1402
+ asset_server: &Res<AssetServer>,
1403
+ gtype: GhostType,
1404
+ pos: (usize, usize),
1405
+ state: GhostState,
1406
+ exit_delay: f32,
1407
+ ) {
1408
+ let (model_path, anim_path, home, light_color) = match gtype {
1409
+ GhostType::Blinky => (
1410
+ "models/ghost_blinky.glb#Scene0",
1411
+ "models/ghost_blinky.glb#Animation0",
1412
+ (0, MAP_WIDTH - 1),
1413
+ Color::srgb(1.0, 0.2, 0.2),
1414
+ ),
1415
+ GhostType::Pinky => (
1416
+ "models/ghost_pinky.glb#Scene0",
1417
+ "models/ghost_pinky.glb#Animation0",
1418
+ (0, 0),
1419
+ Color::srgb(1.0, 0.4, 0.7),
1420
+ ),
1421
+ GhostType::Inky => (
1422
+ "models/ghost_inky.glb#Scene0",
1423
+ "models/ghost_inky.glb#Animation0",
1424
+ (MAP_HEIGHT - 1, MAP_WIDTH - 1),
1425
+ Color::srgb(0.1, 0.8, 1.0),
1426
+ ),
1427
+ GhostType::Clyde => (
1428
+ "models/ghost_clyde.glb#Scene0",
1429
+ "models/ghost_clyde.glb#Animation0",
1430
+ (MAP_HEIGHT - 1, 0),
1431
+ Color::srgb(1.0, 0.6, 0.1),
1432
+ ),
1433
+ };
1434
+
1435
+ let world = grid_to_world(pos.0, pos.1);
1436
+
1437
+ commands
1438
+ .spawn((
1439
+ Ghost {
1440
+ identity: gtype,
1441
+ state,
1442
+ grid_pos: pos,
1443
+ next_tile: pos,
1444
+ progress: 1.0,
1445
+ current_dir: Direction::None,
1446
+ home_corner: home,
1447
+ house_timer: exit_delay,
1448
+ },
1449
+ Transform::from_xyz(world.x, 0.50, world.z),
1450
+ Visibility::default(),
1451
+ ))
1452
+ .with_children(|parent| {
1453
+ // 1. Normal colored visual (diameter 1.16)
1454
+ parent.spawn((
1455
+ NormalVisual,
1456
+ AutoPlaySceneAnimation {
1457
+ clip: asset_server.load(anim_path),
1458
+ },
1459
+ SceneRoot(asset_server.load(model_path)),
1460
+ Transform::IDENTITY,
1461
+ Visibility::Inherited,
1462
+ ));
1463
+
1464
+ // 2. Frightened electric blue visual
1465
+ parent.spawn((
1466
+ FrightenedVisual,
1467
+ AutoPlaySceneAnimation {
1468
+ clip: asset_server.load("models/ghost_frightened.glb#Animation0"),
1469
+ },
1470
+ SceneRoot(asset_server.load("models/ghost_frightened.glb#Scene0")),
1471
+ Transform::IDENTITY,
1472
+ Visibility::Hidden,
1473
+ ));
1474
+
1475
+ // 3. Frightened flashing warning visual
1476
+ parent.spawn((
1477
+ FlashVisual,
1478
+ AutoPlaySceneAnimation {
1479
+ clip: asset_server.load("models/ghost_flash.glb#Animation0"),
1480
+ },
1481
+ SceneRoot(asset_server.load("models/ghost_flash.glb#Scene0")),
1482
+ Transform::IDENTITY,
1483
+ Visibility::Hidden,
1484
+ ));
1485
+
1486
+ // 4. Eaten floating eyes visual
1487
+ parent.spawn((
1488
+ EyesVisual,
1489
+ AutoPlaySceneAnimation {
1490
+ clip: asset_server.load("models/ghost_eyes.glb#Animation0"),
1491
+ },
1492
+ SceneRoot(asset_server.load("models/ghost_eyes.glb#Scene0")),
1493
+ Transform::IDENTITY,
1494
+ Visibility::Hidden,
1495
+ ));
1496
+
1497
+ // 5. Dynamic character aura point light
1498
+ parent.spawn((
1499
+ GhostLight {
1500
+ base_color: light_color,
1501
+ },
1502
+ PointLight {
1503
+ color: light_color,
1504
+ intensity: 45_000.0,
1505
+ range: 4.5,
1506
+ shadows_enabled: false,
1507
+ ..default()
1508
+ },
1509
+ Transform::from_xyz(0.0, 0.40, 0.0),
1510
+ ));
1511
+ });
1512
+ }
1513
+
1514
+ // =========================================================================
1515
+ // Animation & Visual State Systems
1516
+ // =========================================================================
1517
+
1518
+ // Connects Bevy 0.15's AnimationPlayer to glTF AnimationClips via AnimationGraph
1519
+ fn play_scene_animations(
1520
+ mut commands: Commands,
1521
+ mut players: Query<(Entity, &mut AnimationPlayer), Without<AnimationGraphHandle>>,
1522
+ parents: Query<&Parent>,
1523
+ animations: Query<&AutoPlaySceneAnimation>,
1524
+ mut graphs: ResMut<Assets<AnimationGraph>>,
1525
+ ) {
1526
+ for (player_entity, mut player) in players.iter_mut() {
1527
+ let mut curr = player_entity;
1528
+ loop {
1529
+ if let Ok(auto_anim) = animations.get(curr) {
1530
+ let (graph, node_index) = AnimationGraph::from_clip(auto_anim.clip.clone());
1531
+ let graph_handle = graphs.add(graph);
1532
+ commands.entity(player_entity).insert(AnimationGraphHandle(graph_handle));
1533
+ player.play(node_index).repeat();
1534
+ break;
1535
+ }
1536
+ if let Ok(parent) = parents.get(curr) {
1537
+ curr = parent.get();
1538
+ } else {
1539
+ break;
1540
+ }
1541
+ }
1542
+ }
1543
+ }
1544
+
1545
+ // Procedural spinning and hovering for gems, fruit, and energized gyro rings
1546
+ fn procedural_animations(
1547
+ time: Res<Time>,
1548
+ mut pellet_query: Query<(&Pellet, &mut Transform), Without<Pacman>>,
1549
+ mut fruit_query: Query<&mut Transform, (With<CherryFruit>, Without<Pellet>, Without<Pacman>)>,
1550
+ ) {
1551
+ let t = time.elapsed_secs();
1552
+ for (pellet, mut transform) in pellet_query.iter_mut() {
1553
+ if pellet.is_power {
1554
+ let pulse = 1.0 + (t * 5.0 + pellet.seed).sin() * 0.10;
1555
+ transform.scale = Vec3::splat(pulse);
1556
+ transform.rotate_y(time.delta_secs() * 2.0);
1557
+ transform.translation.y = 0.35 + (t * 3.0 + pellet.seed).sin() * 0.03;
1558
+ } else {
1559
+ transform.rotate_y(time.delta_secs() * 2.5);
1560
+ transform.translation.y = 0.25 + (t * 3.5 + pellet.seed).sin() * 0.02;
1561
+ }
1562
+ }
1563
+
1564
+ for mut f_tf in fruit_query.iter_mut() {
1565
+ f_tf.rotate_y(time.delta_secs() * 1.8);
1566
+ f_tf.translation.y = 0.38 + (t * 3.0).sin() * 0.04;
1567
+ }
1568
+ }
1569
+
1570
+ // Manages dynamic ghost color and aura lighting changes: Normal -> Frightened Blue -> Warning Flash -> Eaten Eyes
1571
+ fn ghost_visual_system(
1572
+ time: Res<Time>,
1573
+ gm: Res<GameManager>,
1574
+ ghost_query: Query<(&Ghost, &Children)>,
1575
+ mut vis_query: Query<&mut Visibility>,
1576
+ mut light_query: Query<(&GhostLight, &mut PointLight)>,
1577
+ normal_q: Query<Entity, With<NormalVisual>>,
1578
+ fright_q: Query<Entity, With<FrightenedVisual>>,
1579
+ flash_q: Query<Entity, With<FlashVisual>>,
1580
+ eyes_q: Query<Entity, With<EyesVisual>>,
1581
+ ) {
1582
+ let flash_state = (time.elapsed_secs() * 8.0).sin() > 0.0;
1583
+
1584
+ for (ghost, children) in ghost_query.iter() {
1585
+ let is_frightened = ghost.state == GhostState::Frightened;
1586
+ let is_eaten = ghost.state == GhostState::Eaten;
1587
+ let is_warning = is_frightened && gm.frightened_timer < 2.5;
1588
+
1589
+ for &child in children.iter() {
1590
+ // Update mesh visibility layers
1591
+ if let Ok(mut vis) = vis_query.get_mut(child) {
1592
+ if normal_q.get(child).is_ok() {
1593
+ *vis = if !is_frightened && !is_eaten {
1594
+ Visibility::Inherited
1595
+ } else {
1596
+ Visibility::Hidden
1597
+ };
1598
+ } else if fright_q.get(child).is_ok() {
1599
+ *vis = if is_frightened && (!is_warning || !flash_state) {
1600
+ Visibility::Inherited
1601
+ } else {
1602
+ Visibility::Hidden
1603
+ };
1604
+ } else if flash_q.get(child).is_ok() {
1605
+ *vis = if is_frightened && is_warning && flash_state {
1606
+ Visibility::Inherited
1607
+ } else {
1608
+ Visibility::Hidden
1609
+ };
1610
+ } else if eyes_q.get(child).is_ok() {
1611
+ *vis = if is_eaten {
1612
+ Visibility::Inherited
1613
+ } else {
1614
+ Visibility::Hidden
1615
+ };
1616
+ }
1617
+ }
1618
+
1619
+ // Dynamically update ghost aura point light
1620
+ if let Ok((ghost_light, mut pl)) = light_query.get_mut(child) {
1621
+ if is_eaten {
1622
+ pl.color = Color::srgb(0.2, 0.5, 1.0);
1623
+ pl.intensity = 16_000.0;
1624
+ } else if is_frightened {
1625
+ if is_warning && flash_state {
1626
+ pl.color = Color::srgb(1.0, 1.0, 1.0);
1627
+ pl.intensity = 55_000.0;
1628
+ } else {
1629
+ pl.color = Color::srgb(0.15, 0.35, 1.0);
1630
+ pl.intensity = 40_000.0;
1631
+ }
1632
+ } else {
1633
+ pl.color = ghost_light.base_color;
1634
+ pl.intensity = 45_000.0;
1635
+ }
1636
+ }
1637
+ }
1638
+ }
1639
+ }
1640
+
1641
+ // =========================================================================
1642
+ // Gameplay Systems
1643
+ // =========================================================================
1644
+
1645
+ fn player_input_system(
1646
+ keyboard_input: Res<ButtonInput<KeyCode>>,
1647
+ mut pacman_query: Query<&mut Pacman>,
1648
+ mut gm: ResMut<GameManager>,
1649
+ ) {
1650
+ let mut dir_input = Direction::None;
1651
+ if keyboard_input.pressed(KeyCode::ArrowUp) || keyboard_input.pressed(KeyCode::KeyW) {
1652
+ dir_input = Direction::Up;
1653
+ } else if keyboard_input.pressed(KeyCode::ArrowDown) || keyboard_input.pressed(KeyCode::KeyS) {
1654
+ dir_input = Direction::Down;
1655
+ } else if keyboard_input.pressed(KeyCode::ArrowLeft) || keyboard_input.pressed(KeyCode::KeyA) {
1656
+ dir_input = Direction::Left;
1657
+ } else if keyboard_input.pressed(KeyCode::ArrowRight) || keyboard_input.pressed(KeyCode::KeyD) {
1658
+ dir_input = Direction::Right;
1659
+ }
1660
+
1661
+ if let Ok(mut pacman) = pacman_query.get_single_mut() {
1662
+ if dir_input != Direction::None {
1663
+ pacman.queued_dir = dir_input;
1664
+ if dir_input == pacman.current_dir.opposite() {
1665
+ pacman.current_dir = dir_input;
1666
+ let tmp = pacman.grid_pos;
1667
+ pacman.grid_pos = pacman.next_tile;
1668
+ pacman.next_tile = tmp;
1669
+ pacman.progress = (1.0 - pacman.progress).clamp(0.0, 1.0);
1670
+ }
1671
+ }
1672
+ }
1673
+
1674
+ if keyboard_input.just_pressed(KeyCode::KeyC) {
1675
+ gm.camera_mode = match gm.camera_mode {
1676
+ CameraMode::Isometric => CameraMode::TopDown,
1677
+ CameraMode::TopDown => CameraMode::Follow,
1678
+ CameraMode::Follow => CameraMode::Isometric,
1679
+ };
1680
+ }
1681
+ }
1682
+
1683
+ fn pacman_movement_system(
1684
+ time: Res<Time>,
1685
+ mut pacman_query: Query<(&mut Pacman, &mut Transform)>,
1686
+ gm: Res<GameManager>,
1687
+ ) {
1688
+ if gm.phase != GamePhase::Playing {
1689
+ return;
1690
+ }
1691
+
1692
+ let delta = time.delta_secs();
1693
+
1694
+ for (mut pacman, mut transform) in pacman_query.iter_mut() {
1695
+ if pacman.progress >= 1.0 {
1696
+ pacman.grid_pos = pacman.next_tile;
1697
+
1698
+ // Seamless Tunnel Wrap-Around on row 10
1699
+ if pacman.grid_pos.0 == 10 {
1700
+ if pacman.grid_pos.1 == 0 && pacman.current_dir == Direction::Left {
1701
+ pacman.grid_pos = (10, MAP_WIDTH - 1);
1702
+ pacman.next_tile = (10, MAP_WIDTH - 2);
1703
+ pacman.progress = 0.0;
1704
+ continue;
1705
+ } else if pacman.grid_pos.1 == MAP_WIDTH - 1 && pacman.current_dir == Direction::Right {
1706
+ pacman.grid_pos = (10, 0);
1707
+ pacman.next_tile = (10, 1);
1708
+ pacman.progress = 0.0;
1709
+ continue;
1710
+ }
1711
+ }
1712
+
1713
+ if pacman.queued_dir != Direction::None {
1714
+ let off = pacman.queued_dir.offset();
1715
+ let tr = pacman.grid_pos.0 as i32 + off.0;
1716
+ let tc = pacman.grid_pos.1 as i32 + off.1;
1717
+ if tr >= 0 && tr < MAP_HEIGHT as i32 && tc >= 0 && tc < MAP_WIDTH as i32 {
1718
+ if !is_wall(tr as usize, tc as usize) {
1719
+ pacman.current_dir = pacman.queued_dir;
1720
+ }
1721
+ }
1722
+ }
1723
+
1724
+ if pacman.current_dir != Direction::None {
1725
+ let off = pacman.current_dir.offset();
1726
+ let tr = pacman.grid_pos.0 as i32 + off.0;
1727
+ let tc = pacman.grid_pos.1 as i32 + off.1;
1728
+ if tr >= 0 && tr < MAP_HEIGHT as i32 && tc >= 0 && tc < MAP_WIDTH as i32 {
1729
+ if !is_wall(tr as usize, tc as usize) {
1730
+ pacman.next_tile = (tr as usize, tc as usize);
1731
+ pacman.progress = 0.0;
1732
+ } else {
1733
+ pacman.current_dir = Direction::None;
1734
+ }
1735
+ }
1736
+ }
1737
+ }
1738
+
1739
+ if pacman.progress < 1.0 {
1740
+ pacman.progress = (pacman.progress + delta * pacman.speed).min(1.0);
1741
+ let from = grid_to_world(pacman.grid_pos.0, pacman.grid_pos.1);
1742
+ let to = grid_to_world(pacman.next_tile.0, pacman.next_tile.1);
1743
+ let curr = from.lerp(to, pacman.progress);
1744
+ transform.translation.x = curr.x;
1745
+ transform.translation.z = curr.z;
1746
+
1747
+ let target_rot = pacman.current_dir.to_rotation_y();
1748
+ transform.rotation = Quat::from_rotation_y(target_rot);
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ fn ghost_ai_system(
1754
+ time: Res<Time>,
1755
+ mut ghost_query: Query<(&mut Ghost, &mut Transform)>,
1756
+ pacman_query: Query<(&Pacman, &Transform), Without<Ghost>>,
1757
+ mut gm: ResMut<GameManager>,
1758
+ ) {
1759
+ if gm.phase != GamePhase::Playing {
1760
+ return;
1761
+ }
1762
+
1763
+ let delta = time.delta_secs();
1764
+
1765
+ if gm.frightened_timer > 0.0 {
1766
+ gm.frightened_timer = (gm.frightened_timer - delta).max(0.0);
1767
+ if gm.frightened_timer == 0.0 {
1768
+ for (mut ghost, _) in ghost_query.iter_mut() {
1769
+ if ghost.state == GhostState::Frightened {
1770
+ ghost.state = if gm.is_scatter_mode {
1771
+ GhostState::Scatter
1772
+ } else {
1773
+ GhostState::Chase
1774
+ };
1775
+ }
1776
+ }
1777
+ }
1778
+ } else {
1779
+ gm.scatter_cycle_timer -= delta;
1780
+ if gm.scatter_cycle_timer <= 0.0 {
1781
+ gm.is_scatter_mode = !gm.is_scatter_mode;
1782
+ gm.scatter_cycle_timer = if gm.is_scatter_mode { 7.0 } else { 20.0 };
1783
+ for (mut ghost, _) in ghost_query.iter_mut() {
1784
+ if ghost.state == GhostState::Chase || ghost.state == GhostState::Scatter {
1785
+ ghost.state = if gm.is_scatter_mode {
1786
+ GhostState::Scatter
1787
+ } else {
1788
+ GhostState::Chase
1789
+ };
1790
+ }
1791
+ }
1792
+ }
1793
+ }
1794
+
1795
+ let pacman_pos = pacman_query.get_single().map(|(p, _)| (p.grid_pos, p.current_dir)).unwrap_or(((16, 10), Direction::None));
1796
+ let mut rng = rand::thread_rng();
1797
+
1798
+ for (mut ghost, mut transform) in ghost_query.iter_mut() {
1799
+ if ghost.state == GhostState::InHouse {
1800
+ ghost.house_timer -= delta;
1801
+ if ghost.house_timer <= 0.0 {
1802
+ // Exit smoothly through the cyber gate into corridor (7, 10)
1803
+ ghost.grid_pos = (7, 10);
1804
+ ghost.next_tile = (7, 10);
1805
+ ghost.progress = 1.0;
1806
+ ghost.state = GhostState::Chase;
1807
+ } else {
1808
+ continue;
1809
+ }
1810
+ }
1811
+
1812
+ // Tuned balanced movement speeds: comfortable, classic arcade pacing
1813
+ let speed = match ghost.state {
1814
+ GhostState::Eaten => 5.2,
1815
+ GhostState::Frightened => 1.8,
1816
+ _ => 2.8 + (gm.level as f32 * 0.15),
1817
+ };
1818
+
1819
+ if ghost.progress >= 1.0 {
1820
+ ghost.grid_pos = ghost.next_tile;
1821
+
1822
+ // Tunnel Wrap-Around on row 10
1823
+ if ghost.grid_pos.0 == 10 {
1824
+ if ghost.grid_pos.1 == 0 && ghost.current_dir == Direction::Left {
1825
+ ghost.grid_pos = (10, MAP_WIDTH - 1);
1826
+ ghost.next_tile = (10, MAP_WIDTH - 2);
1827
+ ghost.progress = 0.0;
1828
+ } else if ghost.grid_pos.1 == MAP_WIDTH - 1 && ghost.current_dir == Direction::Right {
1829
+ ghost.grid_pos = (10, 0);
1830
+ ghost.next_tile = (10, 1);
1831
+ ghost.progress = 0.0;
1832
+ }
1833
+ }
1834
+
1835
+ if ghost.state == GhostState::Eaten && ghost.grid_pos == (7, 10) {
1836
+ ghost.state = GhostState::Chase;
1837
+ }
1838
+
1839
+ let target = match ghost.state {
1840
+ GhostState::Eaten => (7, 10),
1841
+ GhostState::Scatter => ghost.home_corner,
1842
+ GhostState::Frightened => (rng.gen_range(0..MAP_HEIGHT), rng.gen_range(0..MAP_WIDTH)),
1843
+ GhostState::Chase => match ghost.identity {
1844
+ GhostType::Blinky => pacman_pos.0,
1845
+ GhostType::Pinky => {
1846
+ let off = pacman_pos.1.offset();
1847
+ (
1848
+ (pacman_pos.0.0 as i32 + off.0 * 4).clamp(0, MAP_HEIGHT as i32 - 1) as usize,
1849
+ (pacman_pos.0.1 as i32 + off.1 * 4).clamp(0, MAP_WIDTH as i32 - 1) as usize,
1850
+ )
1851
+ }
1852
+ GhostType::Inky => {
1853
+ let off = pacman_pos.1.offset();
1854
+ (
1855
+ (pacman_pos.0.0 as i32 + off.0 * 2).clamp(0, MAP_HEIGHT as i32 - 1) as usize,
1856
+ (pacman_pos.0.1 as i32 + off.1 * 2).clamp(0, MAP_WIDTH as i32 - 1) as usize,
1857
+ )
1858
+ }
1859
+ GhostType::Clyde => {
1860
+ let dr = ghost.grid_pos.0 as f32 - pacman_pos.0.0 as f32;
1861
+ let dc = ghost.grid_pos.1 as f32 - pacman_pos.0.1 as f32;
1862
+ if (dr * dr + dc * dc).sqrt() > 6.0 {
1863
+ pacman_pos.0
1864
+ } else {
1865
+ ghost.home_corner
1866
+ }
1867
+ }
1868
+ },
1869
+ GhostState::InHouse => (7, 10),
1870
+ };
1871
+
1872
+ let candidates = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
1873
+ let mut best_dir = Direction::None;
1874
+ let mut best_dist = f32::MAX;
1875
+
1876
+ for &dir in &candidates {
1877
+ if dir == ghost.current_dir.opposite() && ghost.state != GhostState::Frightened {
1878
+ continue;
1879
+ }
1880
+ let off = dir.offset();
1881
+ let nr = ghost.grid_pos.0 as i32 + off.0;
1882
+ let nc = ghost.grid_pos.1 as i32 + off.1;
1883
+ if nr >= 0 && nr < MAP_HEIGHT as i32 && nc >= 0 && nc < MAP_WIDTH as i32 {
1884
+ if !is_wall(nr as usize, nc as usize) {
1885
+ let dr = nr as f32 - target.0 as f32;
1886
+ let dc = nc as f32 - target.1 as f32;
1887
+ let dist = dr * dr + dc * dc;
1888
+ if dist < best_dist {
1889
+ best_dist = dist;
1890
+ best_dir = dir;
1891
+ }
1892
+ }
1893
+ }
1894
+ }
1895
+
1896
+ if best_dir != Direction::None {
1897
+ ghost.current_dir = best_dir;
1898
+ let off = best_dir.offset();
1899
+ ghost.next_tile = (
1900
+ (ghost.grid_pos.0 as i32 + off.0) as usize,
1901
+ (ghost.grid_pos.1 as i32 + off.1) as usize,
1902
+ );
1903
+ ghost.progress = 0.0;
1904
+ }
1905
+ }
1906
+
1907
+ if ghost.progress < 1.0 {
1908
+ ghost.progress = (ghost.progress + delta * speed).min(1.0);
1909
+ let from = grid_to_world(ghost.grid_pos.0, ghost.grid_pos.1);
1910
+ let to = grid_to_world(ghost.next_tile.0, ghost.next_tile.1);
1911
+ let curr = from.lerp(to, ghost.progress);
1912
+ transform.translation.x = curr.x;
1913
+ transform.translation.z = curr.z;
1914
+
1915
+ if ghost.current_dir != Direction::None {
1916
+ let target_rot = ghost.current_dir.to_rotation_y();
1917
+ transform.rotation = Quat::from_rotation_y(target_rot);
1918
+ }
1919
+ }
1920
+ }
1921
+ }
1922
+
1923
+ fn pellet_collection_system(
1924
+ mut commands: Commands,
1925
+ mut pacman_query: Query<(&mut Pacman, &Transform)>,
1926
+ pellet_query: Query<(Entity, &Pellet, &Transform)>,
1927
+ mut ghost_query: Query<&mut Ghost>,
1928
+ mut gm: ResMut<GameManager>,
1929
+ audio_lib: Res<AudioLibrary>,
1930
+ ) {
1931
+ if gm.phase != GamePhase::Playing {
1932
+ return;
1933
+ }
1934
+
1935
+ let Ok((mut pacman, pac_tf)) = pacman_query.get_single_mut() else { return; };
1936
+
1937
+ for (p_ent, pellet, p_tf) in pellet_query.iter() {
1938
+ let dist = pac_tf.translation.distance(p_tf.translation);
1939
+ if dist < 0.65 {
1940
+ commands.entity(p_ent).despawn_recursive();
1941
+ gm.pellets_remaining = gm.pellets_remaining.saturating_sub(1);
1942
+
1943
+ if pellet.is_power {
1944
+ gm.score += 50;
1945
+ gm.frightened_timer = 7.0;
1946
+ gm.ghost_kill_streak = 200;
1947
+
1948
+ for mut ghost in ghost_query.iter_mut() {
1949
+ if ghost.state != GhostState::InHouse && ghost.state != GhostState::Eaten {
1950
+ ghost.state = GhostState::Frightened;
1951
+ }
1952
+ }
1953
+
1954
+ commands.spawn((
1955
+ AudioPlayer(audio_lib.power_pellet.clone()),
1956
+ PlaybackSettings::DESPAWN.with_volume(Volume::new(0.6)),
1957
+ ));
1958
+ } else {
1959
+ gm.score += 10;
1960
+ pacman.chomp_flip = !pacman.chomp_flip;
1961
+ let sound = if pacman.chomp_flip {
1962
+ audio_lib.waka_0.clone()
1963
+ } else {
1964
+ audio_lib.waka_1.clone()
1965
+ };
1966
+ commands.spawn((
1967
+ AudioPlayer(sound),
1968
+ PlaybackSettings::DESPAWN.with_volume(Volume::new(0.4)),
1969
+ ));
1970
+ }
1971
+
1972
+ if gm.pellets_remaining == 0 {
1973
+ gm.phase = GamePhase::Victory;
1974
+ gm.phase_timer = 3.0;
1975
+ }
1976
+ }
1977
+ }
1978
+ }
1979
+
1980
+ fn ghost_collision_system(
1981
+ mut commands: Commands,
1982
+ pacman_query: Query<&Transform, With<Pacman>>,
1983
+ mut ghost_query: Query<(&mut Ghost, &Transform)>,
1984
+ mut gm: ResMut<GameManager>,
1985
+ audio_lib: Res<AudioLibrary>,
1986
+ ) {
1987
+ if gm.phase != GamePhase::Playing {
1988
+ return;
1989
+ }
1990
+
1991
+ let Ok(pac_tf) = pacman_query.get_single() else { return; };
1992
+
1993
+ for (mut ghost, g_tf) in ghost_query.iter_mut() {
1994
+ let dist = pac_tf.translation.distance(g_tf.translation);
1995
+ if dist < 0.75 {
1996
+ if ghost.state == GhostState::Frightened {
1997
+ ghost.state = GhostState::Eaten;
1998
+ let award = gm.ghost_kill_streak;
1999
+ gm.score += award;
2000
+ gm.ghost_kill_streak *= 2;
2001
+
2002
+ commands.spawn((
2003
+ AudioPlayer(audio_lib.eat_ghost.clone()),
2004
+ PlaybackSettings::DESPAWN.with_volume(Volume::new(0.7)),
2005
+ ));
2006
+
2007
+ commands.spawn((
2008
+ FloatingText { lifetime: 1.2 },
2009
+ Text::new(format!("+{}", award)),
2010
+ TextFont { font_size: 28.0, ..default() },
2011
+ TextColor(Color::srgb(0.2, 1.0, 0.4)),
2012
+ Node {
2013
+ position_type: PositionType::Absolute,
2014
+ top: Val::Percent(45.0),
2015
+ left: Val::Percent(50.0),
2016
+ ..default()
2017
+ },
2018
+ ));
2019
+ } else if ghost.state == GhostState::Chase || ghost.state == GhostState::Scatter {
2020
+ gm.phase = GamePhase::PacmanDying;
2021
+ gm.phase_timer = 2.0;
2022
+
2023
+ commands.spawn((
2024
+ AudioPlayer(audio_lib.death.clone()),
2025
+ PlaybackSettings::DESPAWN.with_volume(Volume::new(0.75)),
2026
+ ));
2027
+ break;
2028
+ }
2029
+ }
2030
+ }
2031
+ }
2032
+
2033
+ // Cherry fruit bonus collection and cyclic re-spawning
2034
+ fn fruit_system(
2035
+ mut commands: Commands,
2036
+ time: Res<Time>,
2037
+ pacman_query: Query<&Transform, With<Pacman>>,
2038
+ mut fruit_query: Query<(Entity, &mut CherryFruit, &Transform)>,
2039
+ mut gm: ResMut<GameManager>,
2040
+ audio_lib: Res<AudioLibrary>,
2041
+ asset_server: Res<AssetServer>,
2042
+ ) {
2043
+ let delta = time.delta_secs();
2044
+ let Ok(pac_tf) = pacman_query.get_single() else { return; };
2045
+
2046
+ let mut fruit_count = 0;
2047
+ for (f_ent, mut fruit, f_tf) in fruit_query.iter_mut() {
2048
+ fruit_count += 1;
2049
+ fruit.timer -= delta;
2050
+ if fruit.timer <= 0.0 {
2051
+ commands.entity(f_ent).despawn_recursive();
2052
+ gm.fruit_respawn_timer = 15.0;
2053
+ continue;
2054
+ }
2055
+
2056
+ if pac_tf.translation.distance(f_tf.translation) < 0.85 {
2057
+ gm.score += 100;
2058
+ commands.entity(f_ent).despawn_recursive();
2059
+ gm.fruit_respawn_timer = 20.0;
2060
+
2061
+ commands.spawn((
2062
+ AudioPlayer(audio_lib.fruit.clone()),
2063
+ PlaybackSettings::DESPAWN.with_volume(Volume::new(0.65)),
2064
+ ));
2065
+
2066
+ commands.spawn((
2067
+ FloatingText { lifetime: 1.2 },
2068
+ Text::new("+100"),
2069
+ TextFont { font_size: 28.0, ..default() },
2070
+ TextColor(Color::srgb(1.0, 0.4, 0.7)),
2071
+ Node {
2072
+ position_type: PositionType::Absolute,
2073
+ top: Val::Percent(45.0),
2074
+ left: Val::Percent(50.0),
2075
+ ..default()
2076
+ },
2077
+ ));
2078
+ }
2079
+ }
2080
+
2081
+ // Cyclic re-spawning: when cherry is eaten or expires, a new one appears after cooldown
2082
+ if fruit_count == 0 && gm.phase == GamePhase::Playing {
2083
+ gm.fruit_respawn_timer -= delta;
2084
+ if gm.fruit_respawn_timer <= 0.0 {
2085
+ spawn_bonus_cherry(&mut commands, &asset_server);
2086
+ gm.fruit_respawn_timer = 25.0;
2087
+ }
2088
+ }
2089
+ }
2090
+
2091
+ fn floating_text_system(
2092
+ mut commands: Commands,
2093
+ time: Res<Time>,
2094
+ mut text_query: Query<(Entity, &mut FloatingText)>,
2095
+ ) {
2096
+ let delta = time.delta_secs();
2097
+ for (entity, mut float_txt) in text_query.iter_mut() {
2098
+ float_txt.lifetime -= delta;
2099
+ if float_txt.lifetime <= 0.0 {
2100
+ commands.entity(entity).despawn_recursive();
2101
+ }
2102
+ }
2103
+ }
2104
+
2105
+ fn camera_follow_system(
2106
+ mut camera_query: Query<&mut Transform, (With<MainCamera>, Without<Pacman>)>,
2107
+ pacman_query: Query<&Transform, With<Pacman>>,
2108
+ gm: Res<GameManager>,
2109
+ ) {
2110
+ let Ok(pac_tf) = pacman_query.get_single() else { return; };
2111
+ let Ok(mut cam_tf) = camera_query.get_single_mut() else { return; };
2112
+
2113
+ match gm.camera_mode {
2114
+ CameraMode::Isometric => {
2115
+ let target = Vec3::new(0.0, 24.0, 16.0);
2116
+ cam_tf.translation = cam_tf.translation.lerp(target, 0.08);
2117
+ cam_tf.look_at(Vec3::new(0.0, 0.0, 0.5), Dir3::Y);
2118
+ }
2119
+ CameraMode::TopDown => {
2120
+ let target = Vec3::new(0.0, 28.0, 0.001);
2121
+ cam_tf.translation = cam_tf.translation.lerp(target, 0.08);
2122
+ cam_tf.look_at(Vec3::ZERO, Dir3::NEG_Z);
2123
+ }
2124
+ CameraMode::Follow => {
2125
+ let target = pac_tf.translation + Vec3::new(0.0, 8.5, 7.0);
2126
+ cam_tf.translation = cam_tf.translation.lerp(target, 0.12);
2127
+ cam_tf.look_at(pac_tf.translation + Vec3::new(0.0, 0.5, 0.0), Dir3::Y);
2128
+ }
2129
+ }
2130
+ }
2131
+
2132
+ // Complete field & characters reset to pristine initial state
2133
+ fn reset_field_and_characters(
2134
+ commands: &mut Commands,
2135
+ asset_server: &Res<AssetServer>,
2136
+ pellet_query: &Query<Entity, With<Pellet>>,
2137
+ fruit_query: &Query<Entity, With<CherryFruit>>,
2138
+ pacman_query: &mut Query<(&mut Pacman, &mut Transform)>,
2139
+ ghost_query: &mut Query<(&mut Ghost, &mut Transform), Without<Pacman>>,
2140
+ ) -> usize {
2141
+ // 1. Despawn any existing pellets and fruit
2142
+ for entity in pellet_query.iter() {
2143
+ commands.entity(entity).despawn_recursive();
2144
+ }
2145
+ for entity in fruit_query.iter() {
2146
+ commands.entity(entity).despawn_recursive();
2147
+ }
2148
+
2149
+ // 2. Respawn the complete maze pellet field
2150
+ let total = spawn_field_pellets(commands, asset_server);
2151
+
2152
+ // 3. Respawn the bonus Cherry fruit at (13, 10)
2153
+ spawn_bonus_cherry(commands, asset_server);
2154
+
2155
+ // 4. Reset Pacman to initial spawn position and state
2156
+ if let Ok((mut pacman, mut p_tf)) = pacman_query.get_single_mut() {
2157
+ pacman.grid_pos = (16, 10);
2158
+ pacman.next_tile = (16, 10);
2159
+ pacman.progress = 1.0;
2160
+ pacman.current_dir = Direction::None;
2161
+ pacman.queued_dir = Direction::None;
2162
+ pacman.chomp_flip = false;
2163
+ let world = grid_to_world(16, 10);
2164
+ p_tf.translation = Vec3::new(world.x, 0.50, world.z);
2165
+ p_tf.rotation = Quat::IDENTITY;
2166
+ }
2167
+
2168
+ // 5. Reset all Ghosts to initial home positions and states
2169
+ // Blinky at (7, 10), Pinky at (10, 10), Inky at (10, 9), Clyde at (10, 11)
2170
+ for (mut ghost, mut g_tf) in ghost_query.iter_mut() {
2171
+ let (start_pos, state, delay) = match ghost.identity {
2172
+ GhostType::Blinky => ((7, 10), GhostState::Scatter, 0.0),
2173
+ GhostType::Pinky => ((10, 10), GhostState::InHouse, 2.0),
2174
+ GhostType::Inky => ((10, 9), GhostState::InHouse, 4.0),
2175
+ GhostType::Clyde => ((10, 11), GhostState::InHouse, 6.0),
2176
+ };
2177
+ ghost.grid_pos = start_pos;
2178
+ ghost.next_tile = start_pos;
2179
+ ghost.progress = 1.0;
2180
+ ghost.state = state;
2181
+ ghost.current_dir = Direction::None;
2182
+ ghost.house_timer = delay;
2183
+ let world = grid_to_world(start_pos.0, start_pos.1);
2184
+ g_tf.translation = Vec3::new(world.x, 0.50, world.z);
2185
+ g_tf.rotation = Quat::IDENTITY;
2186
+ }
2187
+
2188
+ total
2189
+ }
2190
+
2191
+ fn game_state_manager_system(
2192
+ mut commands: Commands,
2193
+ asset_server: Res<AssetServer>,
2194
+ time: Res<Time>,
2195
+ mut gm: ResMut<GameManager>,
2196
+ mut pacman_query: Query<(&mut Pacman, &mut Transform)>,
2197
+ mut ghost_query: Query<(&mut Ghost, &mut Transform), Without<Pacman>>,
2198
+ pellet_query: Query<Entity, With<Pellet>>,
2199
+ fruit_query: Query<Entity, With<CherryFruit>>,
2200
+ mut banner_query: Query<(&mut Text, &mut Visibility), With<BannerText>>,
2201
+ keyboard_input: Res<ButtonInput<KeyCode>>,
2202
+ ) {
2203
+ let delta = time.delta_secs();
2204
+
2205
+ if gm.score > gm.high_score {
2206
+ gm.high_score = gm.score;
2207
+ }
2208
+
2209
+ // Hotkey 'R' for instant full reset at any time
2210
+ if keyboard_input.just_pressed(KeyCode::KeyR) {
2211
+ gm.score = 0;
2212
+ gm.lives = 3;
2213
+ gm.level = 1;
2214
+ gm.phase = GamePhase::Ready;
2215
+ gm.phase_timer = 2.0;
2216
+ gm.frightened_timer = 0.0;
2217
+ gm.fruit_respawn_timer = 0.0;
2218
+ gm.pellets_remaining = reset_field_and_characters(
2219
+ &mut commands,
2220
+ &asset_server,
2221
+ &pellet_query,
2222
+ &fruit_query,
2223
+ &mut pacman_query,
2224
+ &mut ghost_query,
2225
+ );
2226
+ return;
2227
+ }
2228
+
2229
+ let Ok((mut banner_text, mut banner_vis)) = banner_query.get_single_mut() else { return; };
2230
+
2231
+ match gm.phase {
2232
+ GamePhase::Ready => {
2233
+ *banner_vis = Visibility::Visible;
2234
+ banner_text.0 = "READY!".to_string();
2235
+ gm.phase_timer -= delta;
2236
+ if gm.phase_timer <= 0.0 {
2237
+ gm.phase = GamePhase::Playing;
2238
+ *banner_vis = Visibility::Hidden;
2239
+ }
2240
+ }
2241
+ GamePhase::Playing => {
2242
+ *banner_vis = Visibility::Hidden;
2243
+ }
2244
+ GamePhase::PacmanDying => {
2245
+ *banner_vis = Visibility::Visible;
2246
+ banner_text.0 = "OUCH!".to_string();
2247
+ gm.phase_timer -= delta;
2248
+ if gm.phase_timer <= 0.0 {
2249
+ if gm.lives > 1 {
2250
+ gm.lives -= 1;
2251
+ gm.phase = GamePhase::Ready;
2252
+ gm.phase_timer = 2.0;
2253
+ gm.frightened_timer = 0.0;
2254
+
2255
+ // Reposition characters to initial starting locations
2256
+ if let Ok((mut pac, mut p_tf)) = pacman_query.get_single_mut() {
2257
+ pac.grid_pos = (16, 10);
2258
+ pac.next_tile = (16, 10);
2259
+ pac.progress = 1.0;
2260
+ pac.current_dir = Direction::None;
2261
+ pac.queued_dir = Direction::None;
2262
+ let world = grid_to_world(16, 10);
2263
+ p_tf.translation = Vec3::new(world.x, 0.50, world.z);
2264
+ p_tf.rotation = Quat::IDENTITY;
2265
+ }
2266
+
2267
+ for (mut ghost, mut g_tf) in ghost_query.iter_mut() {
2268
+ let (start_pos, state, delay) = match ghost.identity {
2269
+ GhostType::Blinky => ((7, 10), GhostState::Scatter, 0.0),
2270
+ GhostType::Pinky => ((10, 10), GhostState::InHouse, 2.0),
2271
+ GhostType::Inky => ((10, 9), GhostState::InHouse, 4.0),
2272
+ GhostType::Clyde => ((10, 11), GhostState::InHouse, 6.0),
2273
+ };
2274
+ ghost.grid_pos = start_pos;
2275
+ ghost.next_tile = start_pos;
2276
+ ghost.progress = 1.0;
2277
+ ghost.state = state;
2278
+ ghost.current_dir = Direction::None;
2279
+ ghost.house_timer = delay;
2280
+ let world = grid_to_world(start_pos.0, start_pos.1);
2281
+ g_tf.translation = Vec3::new(world.x, 0.50, world.z);
2282
+ g_tf.rotation = Quat::IDENTITY;
2283
+ }
2284
+ } else {
2285
+ gm.lives = 0;
2286
+ gm.phase = GamePhase::GameOver;
2287
+ }
2288
+ }
2289
+ }
2290
+ GamePhase::GameOver => {
2291
+ *banner_vis = Visibility::Visible;
2292
+ banner_text.0 = "GAME OVER - PRESS SPACE TO RETRY".to_string();
2293
+ if keyboard_input.just_pressed(KeyCode::Space) {
2294
+ gm.score = 0;
2295
+ gm.lives = 3;
2296
+ gm.level = 1;
2297
+ gm.phase = GamePhase::Ready;
2298
+ gm.phase_timer = 2.0;
2299
+ gm.frightened_timer = 0.0;
2300
+ gm.fruit_respawn_timer = 0.0;
2301
+ // Full field and characters reset
2302
+ gm.pellets_remaining = reset_field_and_characters(
2303
+ &mut commands,
2304
+ &asset_server,
2305
+ &pellet_query,
2306
+ &fruit_query,
2307
+ &mut pacman_query,
2308
+ &mut ghost_query,
2309
+ );
2310
+ }
2311
+ }
2312
+ GamePhase::Victory => {
2313
+ *banner_vis = Visibility::Visible;
2314
+ banner_text.0 = "STAGE CLEARED!".to_string();
2315
+ gm.phase_timer -= delta;
2316
+ if gm.phase_timer <= 0.0 {
2317
+ gm.level += 1;
2318
+ gm.phase = GamePhase::Ready;
2319
+ gm.phase_timer = 2.0;
2320
+ gm.frightened_timer = 0.0;
2321
+ gm.fruit_respawn_timer = 0.0;
2322
+ // Respawn new field pellets and fresh cherry for next level
2323
+ gm.pellets_remaining = reset_field_and_characters(
2324
+ &mut commands,
2325
+ &asset_server,
2326
+ &pellet_query,
2327
+ &fruit_query,
2328
+ &mut pacman_query,
2329
+ &mut ghost_query,
2330
+ );
2331
+ }
2332
+ }
2333
+ }
2334
+ }
2335
+
2336
+ fn hud_update_system(
2337
+ gm: Res<GameManager>,
2338
+ mut score_query: Query<&mut Text, (With<ScoreText>, Without<LivesText>)>,
2339
+ mut lives_query: Query<&mut Text, (With<LivesText>, Without<ScoreText>)>,
2340
+ ) {
2341
+ if let Ok(mut score_text) = score_query.get_single_mut() {
2342
+ score_text.0 = format!("SCORE: {:06} HIGH: {:06}", gm.score, gm.high_score);
2343
+ }
2344
+ if let Ok(mut lives_text) = lives_query.get_single_mut() {
2345
+ lives_text.0 = format!("LIVES: {} LEVEL: {} [C: CAM] [R: RESET]", gm.lives, gm.level);
2346
+ }
2347
+ }
2348
+
2349
+ // =========================================================================
2350
+ // Main Entrypoint
2351
+ // =========================================================================
2352
+
2353
+ fn main() {
2354
+ // 1. Synthesize procedural sound assets on launch
2355
+ generate_procedural_sounds();
2356
+
2357
+ // 2. Launch Bevy Game Engine
2358
+ App::new()
2359
+ .add_plugins(DefaultPlugins.set(WindowPlugin {
2360
+ primary_window: Some(Window {
2361
+ title: "PAC-MAN 3D // Cyber Maze Edition".into(),
2362
+ resolution: (1280.0_f32, 768.0_f32).into(),
2363
+ resizable: true,
2364
+ ..default()
2365
+ }),
2366
+ ..default()
2367
+ }))
2368
+ .insert_resource(AmbientLight {
2369
+ color: Color::srgb(0.08, 0.10, 0.22),
2370
+ brightness: 260.0,
2371
+ })
2372
+ .add_systems(Startup, setup_game)
2373
+ .add_systems(
2374
+ Update,
2375
+ (
2376
+ player_input_system,
2377
+ pacman_movement_system,
2378
+ ghost_ai_system,
2379
+ ghost_visual_system,
2380
+ pellet_collection_system,
2381
+ ghost_collision_system,
2382
+ fruit_system,
2383
+ floating_text_system,
2384
+ camera_follow_system,
2385
+ game_state_manager_system,
2386
+ hud_update_system,
2387
+ play_scene_animations,
2388
+ procedural_animations,
2389
+ ),
2390
+ )
2391
+ .run();
2392
+ }
2393
+ `,
2394
+ );
2395
+
2396
+ console.log("\n======================================================");
2397
+ console.log('Project "packman-3d" successfully configured!');
2398
+ console.log("Features & Fixes:");
2399
+ console.log(
2400
+ " 1. Cherry is spawned at the start beneath the Ghost House and cyclically re-spawns",
2401
+ );
2402
+ console.log(
2403
+ " 2. Ghost start positions corrected: Blinky starts in open corridor (7, 10)",
2404
+ );
2405
+ console.log(" 3. Procedural 3D Cyber Energy Gate energy barrier");
2406
+ console.log(
2407
+ " 4. Full field & character reset on retry, stage clear, or [R] key",
2408
+ );
2409
+ console.log(
2410
+ " 5. Character point lights added to Pacman and all Ghosts with dynamic aura adaptation",
2411
+ );
2412
+ console.log("======================================================\n");
2413
+ console.log("To run the game:");
2414
+ console.log(" cd packman_3d");
2415
+ console.log(" cargo run\n");