scad-gltf 0.2.1 → 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.
- package/bevy/README.md +46 -0
- package/bevy/examples/LICENSE +21 -0
- package/bevy/examples/README.md +58 -0
- package/bevy/examples/package.json +5 -0
- package/bevy/examples/packman_3d.js +2415 -0
- package/package.json +3 -1
|
@@ -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 ¬es {
|
|
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");
|