scad-gltf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +489 -0
  2. package/bin/scad-convert.js +221 -0
  3. package/bin/scad-godot.js +247 -0
  4. package/bin/scad-mcp.js +508 -0
  5. package/bin/scad-serve.js +225 -0
  6. package/bin/scad-web.js +206 -0
  7. package/editor/dist/aristea_wreck_puresky_2k.hdr +0 -0
  8. package/editor/dist/assets/OutputPass-Bvl6NigM.js +4317 -0
  9. package/editor/dist/assets/index-V9cEH4KX.js +4318 -0
  10. package/editor/dist/assets/index-t9MYrExo.css +1 -0
  11. package/editor/dist/assets/openscad-CdBCY4mx.wasm +0 -0
  12. package/editor/dist/assets/preview-C1zc24MJ.js +1 -0
  13. package/editor/dist/assets/preview-fQfL_FJ-.css +1 -0
  14. package/editor/dist/assets/prompt-ui-8EFRz0ju.js +126 -0
  15. package/editor/dist/content-loader.js +4 -0
  16. package/editor/dist/content.css +255 -0
  17. package/editor/dist/content.js +24 -0
  18. package/editor/dist/icon.png +0 -0
  19. package/editor/dist/index.html +187 -0
  20. package/editor/dist/manifest.json +23 -0
  21. package/editor/dist/manifest.webmanifest +1 -0
  22. package/editor/dist/preview.html +27 -0
  23. package/editor/dist/registerSW.js +1 -0
  24. package/editor/dist/sw.js +1 -0
  25. package/editor/dist/workbox-9c191d2f.js +1 -0
  26. package/godot/README.md +64 -0
  27. package/godot/addons/scad_importer/plugin.cfg +7 -0
  28. package/godot/addons/scad_importer/scad_importer.gd +197 -0
  29. package/godot/addons/scad_importer/scad_plugin.gd +12 -0
  30. package/godot/examples/README.md +82 -0
  31. package/godot/examples/fruit_fusion_3d.js +1574 -0
  32. package/godot/examples/package.json +5 -0
  33. package/package.json +63 -0
  34. package/src/convert.js +94 -0
  35. package/src/ext/openscad.js +14 -0
  36. package/src/ext/openscad.wasm +0 -0
  37. package/src/prompt.js +229 -0
@@ -0,0 +1,1574 @@
1
+ #!/usr/bin/env node
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ const PROJECT_DIR = "fruit_fusion_3d";
6
+
7
+ function ensureDir(dirPath) {
8
+ if (!fs.existsSync(dirPath)) {
9
+ fs.mkdirSync(dirPath, { recursive: true });
10
+ }
11
+ }
12
+
13
+ function writeFile(relPath, content) {
14
+ const fullPath = path.join(PROJECT_DIR, relPath);
15
+ ensureDir(path.dirname(fullPath));
16
+ fs.writeFileSync(fullPath, content, "utf8");
17
+ console.log(`[Created] ${relPath}`);
18
+ }
19
+
20
+ console.log(`Setting up project in ./${PROJECT_DIR}...`);
21
+
22
+ // ==========================================
23
+ // 1. ADDON FILES
24
+ // ==========================================
25
+
26
+ writeFile(
27
+ "addons/scad_importer/plugin.cfg",
28
+ `[plugin]
29
+
30
+ name="OpenSCAD GLTF Importer"
31
+ description="Imports .scad files directly as 3D scenes using scad-gltf"
32
+ author="Ilia Grigorev"
33
+ version="0.1"
34
+ script="scad_plugin.gd"
35
+ `,
36
+ );
37
+
38
+ writeFile(
39
+ "addons/scad_importer/scad_plugin.gd",
40
+ `@tool
41
+ extends EditorPlugin
42
+
43
+ var import_plugin
44
+
45
+ func _enter_tree():
46
+ import_plugin = preload("res://addons/scad_importer/scad_importer.gd").new()
47
+ add_scene_format_importer_plugin(import_plugin)
48
+
49
+ func _exit_tree():
50
+ remove_scene_format_importer_plugin(import_plugin)
51
+ import_plugin = null
52
+ `,
53
+ );
54
+
55
+ writeFile(
56
+ "addons/scad_importer/scad_importer.gd",
57
+ `@tool
58
+ extends EditorSceneFormatImporter
59
+
60
+ func _get_extensions():
61
+ return PackedStringArray(["scad"])
62
+
63
+ func _get_import_flags():
64
+ return EditorSceneFormatImporter.IMPORT_SCENE
65
+
66
+ func _import_scene(path: String, flags: int, options: Dictionary) -> Object:
67
+ var global_source = ProjectSettings.globalize_path(path)
68
+ var unique_id = str(hash(path))
69
+ var temp_glb_path = ProjectSettings.globalize_path("user://scad_cache_" + unique_id + ".glb")
70
+
71
+ var args = PackedStringArray()
72
+ args.append(global_source)
73
+ args.append(temp_glb_path)
74
+
75
+ var output = []
76
+ print("Importing %s via scad-convert... (This might take a few seconds on the first run)" % path.get_file())
77
+
78
+ var exit_code = -1
79
+ if OS.get_name() == "Windows":
80
+ var win_args = PackedStringArray(["/c", "scad-convert"])
81
+ win_args.append_array(args)
82
+ exit_code = OS.execute("cmd.exe", win_args, output, true)
83
+ else:
84
+ exit_code = OS.execute("scad-convert", args, output, true)
85
+
86
+ if exit_code != 0:
87
+ print("scad-convert conversion failed for %s. Attempting fallback to local scad-serve..." % path.get_file())
88
+ var fallback_success = _try_scad_serve_fallback(global_source, temp_glb_path)
89
+
90
+ if not fallback_success:
91
+ push_error("Failed to compile SCAD file: %s. Ensure Node.js is installed or scad-serve is running." % path.get_file())
92
+ push_error("scad-convert output: ", "\\n".join(output))
93
+ return null
94
+
95
+ var gltf_doc = GLTFDocument.new()
96
+ var gltf_state = GLTFState.new()
97
+ var err = gltf_doc.append_from_file(temp_glb_path, gltf_state)
98
+
99
+ if FileAccess.file_exists(temp_glb_path):
100
+ DirAccess.remove_absolute(temp_glb_path)
101
+
102
+ if err != OK:
103
+ push_error("Failed to parse the generated GLB for %s." % path.get_file())
104
+ return null
105
+
106
+ var generated_scene = gltf_doc.generate_scene(gltf_state)
107
+ if generated_scene:
108
+ generated_scene.name = path.get_file().get_basename()
109
+
110
+ return generated_scene
111
+
112
+ func _get_relative_path(base: String, target: String) -> String:
113
+ var base_parts = base.replace("\\\\", "/").split("/", false)
114
+ var target_parts = target.replace("\\\\", "/").split("/", false)
115
+
116
+ if OS.get_name() == "Windows":
117
+ if base_parts.size() > 0 and target_parts.size() > 0:
118
+ if base_parts[0].nocasecmp_to(target_parts[0]) != 0:
119
+ return target
120
+
121
+ var common_count = 0
122
+ var min_len = min(base_parts.size(), target_parts.size())
123
+ for i in range(min_len):
124
+ if base_parts[i].nocasecmp_to(target_parts[i]) == 0:
125
+ common_count += 1
126
+ else:
127
+ break
128
+
129
+ var rel_parts = PackedStringArray()
130
+ for i in range(common_count, base_parts.size()):
131
+ rel_parts.append("..")
132
+
133
+ for i in range(common_count, target_parts.size()):
134
+ rel_parts.append(target_parts[i])
135
+
136
+ return "/".join(rel_parts)
137
+
138
+ func _get_dependencies_recursive(file_path: String, visited: Dictionary) -> void:
139
+ if visited.has(file_path):
140
+ return
141
+
142
+ visited[file_path] = ""
143
+
144
+ if not FileAccess.file_exists(file_path):
145
+ return
146
+
147
+ var file = FileAccess.open(file_path, FileAccess.READ)
148
+ if not file:
149
+ return
150
+
151
+ var content = file.get_as_text()
152
+ file.close()
153
+
154
+ visited[file_path] = content
155
+
156
+ var regex = RegEx.new()
157
+ regex.compile("(?:include|use)\\\\s*[<\\"]([^>\\"]+)[>\\"]")
158
+
159
+ var base_dir = file_path.get_base_dir()
160
+ for result in regex.search_all(content):
161
+ var dep_rel_path = result.get_string(1)
162
+ var dep_abs_path = base_dir.path_join(dep_rel_path).simplify_path()
163
+ _get_dependencies_recursive(dep_abs_path, visited)
164
+
165
+ func _get_dependencies(file_path: String) -> Dictionary:
166
+ var visited = {}
167
+ _get_dependencies_recursive(file_path, visited)
168
+ return visited
169
+
170
+ func _try_scad_serve_fallback(source_path: String, out_glb_path: String) -> bool:
171
+ var deps = _get_dependencies(source_path)
172
+ var content = deps.get(source_path, "")
173
+ deps.erase(source_path)
174
+
175
+ if content == "":
176
+ return false
177
+
178
+ var additional_files = {}
179
+ var base_dir = source_path.get_base_dir()
180
+ for dep_path in deps.keys():
181
+ var rel_path = _get_relative_path(base_dir, dep_path)
182
+ additional_files[rel_path] = deps[dep_path]
183
+
184
+ var http = HTTPClient.new()
185
+ var err = http.connect_to_host("127.0.0.1", 3000)
186
+ if err != OK:
187
+ return false
188
+
189
+ var max_wait = 500
190
+ var wait = 0
191
+ while http.get_status() in [HTTPClient.STATUS_CONNECTING, HTTPClient.STATUS_RESOLVING]:
192
+ http.poll()
193
+ OS.delay_msec(10)
194
+ wait += 1
195
+ if wait > max_wait:
196
+ return false
197
+
198
+ if http.get_status() != HTTPClient.STATUS_CONNECTED:
199
+ return false
200
+
201
+ var headers = PackedStringArray(["Content-Type: application/json"])
202
+
203
+ var payload = {
204
+ "content": content,
205
+ "options": {
206
+ "additionalFiles": additional_files
207
+ }
208
+ }
209
+
210
+ var body = JSON.stringify(payload)
211
+ err = http.request(HTTPClient.METHOD_POST, "/api/convert", headers, body)
212
+ if err != OK:
213
+ return false
214
+
215
+ max_wait = 6000
216
+ wait = 0
217
+ while http.get_status() == HTTPClient.STATUS_REQUESTING:
218
+ http.poll()
219
+ OS.delay_msec(10)
220
+ wait += 1
221
+ if wait > max_wait:
222
+ return false
223
+
224
+ if http.has_response() and http.get_response_code() == 200:
225
+ var rb = PackedByteArray()
226
+ while http.get_status() == HTTPClient.STATUS_BODY:
227
+ http.poll()
228
+ var chunk = http.read_response_body_chunk()
229
+ if chunk.size() == 0:
230
+ OS.delay_msec(10)
231
+ else:
232
+ rb.append_array(chunk)
233
+
234
+ if rb.is_empty():
235
+ return false
236
+
237
+ var out_file = FileAccess.open(out_glb_path, FileAccess.WRITE)
238
+ if not out_file:
239
+ return false
240
+ out_file.store_buffer(rb)
241
+ out_file.close()
242
+
243
+ print("Successfully compiled %s using scad-serve fallback." % source_path.get_file())
244
+ return true
245
+
246
+ return false
247
+ `,
248
+ );
249
+
250
+ // ==========================================
251
+ // 2. PROCEDURAL 3D ASSETS (.scad)
252
+ // ==========================================
253
+
254
+ // Container Glass Box with Wooden Border and Danger Marker
255
+ writeFile(
256
+ "assets/models/container.scad",
257
+ `$fn = 28;
258
+ $asa = 45;
259
+
260
+ box_w = 12.0;
261
+ box_d = 4.0;
262
+ box_h = 16.0;
263
+ wall_t = 0.2;
264
+
265
+ // Left, Right & Back Transparent Glass (alpha=0.15 triggers glTF BLEND transparency mode)
266
+ color([0.80, 0.93, 1.0], alpha=0.15, roughness=0.04, $asa=45) {
267
+ // Back glass plate
268
+ translate([0, box_d/2 + wall_t/2, box_h/2])
269
+ cube([box_w, wall_t, box_h], center=true);
270
+
271
+ // Left glass plate
272
+ translate([-box_w/2 - wall_t/2, 0, box_h/2])
273
+ cube([wall_t, box_d, box_h], center=true);
274
+
275
+ // Right glass plate
276
+ translate([box_w/2 + wall_t/2, 0, box_h/2])
277
+ cube([wall_t, box_d, box_h], center=true);
278
+ }
279
+
280
+ // Sturdy Polished Wooden Base
281
+ color([0.55, 0.32, 0.16], roughness=0.75) {
282
+ translate([0, 0, -0.4])
283
+ cube([box_w + 1.6, box_d + 1.6, 0.8], center=true);
284
+ }
285
+
286
+ // Metallic Corner Columns & Base Trim
287
+ color([0.85, 0.88, 0.92], metalness=0.9, roughness=0.2) {
288
+ for (sx = [-1, 1]) {
289
+ for (sy = [-1, 1]) {
290
+ translate([sx * (box_w/2 + wall_t), sy * (box_d/2 + wall_t), box_h/2])
291
+ cylinder(r=0.22, h=box_h, center=true);
292
+ }
293
+ }
294
+ // Sleek metallic floor plate
295
+ translate([0, 0, 0.05])
296
+ cube([box_w, box_d, 0.1], center=true);
297
+ }
298
+ `,
299
+ );
300
+
301
+ // Cute Dropper / Aiming Cloud
302
+ writeFile(
303
+ "assets/models/dropper.scad",
304
+ `$fn = 24;
305
+ $asa = 45;
306
+
307
+ color([0.98, 0.98, 1.0], roughness=0.4) {
308
+ translate([0, 0, 0]) sphere(r=0.9);
309
+ translate([-0.8, 0, -0.15]) sphere(r=0.65);
310
+ translate([0.8, 0, -0.15]) sphere(r=0.65);
311
+ translate([-1.4, 0, -0.3]) sphere(r=0.45);
312
+ translate([1.4, 0, -0.3]) sphere(r=0.45);
313
+ }
314
+
315
+ color([1.0, 0.45, 0.55], emissive=[0.8, 0.2, 0.3], emissiveIntensity=0.8, roughness=0.5) {
316
+ translate([-0.65, 0.55, -0.2]) sphere(r=0.18);
317
+ translate([0.65, 0.55, -0.2]) sphere(r=0.18);
318
+ }
319
+
320
+ color([1.0, 0.85, 0.1], emissive=[1.0, 0.7, 0.0], emissiveIntensity=1.8, metalness=0.3, roughness=0.3) {
321
+ translate([0, 0, -0.95])
322
+ cylinder(r1=0.25, r2=0.02, h=0.5, center=true);
323
+ }
324
+ `,
325
+ );
326
+
327
+ // Tier 1: Cherry
328
+ writeFile(
329
+ "assets/models/fruit_1.scad",
330
+ `$fn = 26;
331
+ $asa = 45;
332
+ r = 0.55;
333
+
334
+ color([0.85, 0.05, 0.15], roughness=0.15) {
335
+ sphere(r=r);
336
+ }
337
+
338
+ color([0.25, 0.65, 0.15], roughness=0.6) {
339
+ translate([0, 0, r * 0.9])
340
+ cylinder(r=0.08, h=0.12, center=true);
341
+ translate([0.05, 0, r + 0.25])
342
+ rotate([0, 15, 0])
343
+ cylinder(r=0.04, h=0.5, center=true);
344
+ translate([0.18, 0, r + 0.35])
345
+ rotate([0, 45, 10])
346
+ cube([0.22, 0.1, 0.04], center=true);
347
+ }
348
+ `,
349
+ );
350
+
351
+ // Tier 2: Strawberry
352
+ writeFile(
353
+ "assets/models/fruit_2.scad",
354
+ `$fn = 26;
355
+ $asa = 45;
356
+
357
+ color([0.95, 0.12, 0.28], roughness=0.3) {
358
+ scale([1.0, 1.0, 1.25])
359
+ sphere(r=0.72);
360
+ }
361
+
362
+ color([0.18, 0.75, 0.2], roughness=0.5) {
363
+ for (i = [0:5]) {
364
+ rotate([0, 0, i * 60])
365
+ translate([0.35, 0, 0.82])
366
+ rotate([0, -25, 0])
367
+ cube([0.35, 0.14, 0.05], center=true);
368
+ }
369
+ translate([0, 0, 0.98])
370
+ cylinder(r=0.06, h=0.25, center=true);
371
+ }
372
+
373
+ color([1.0, 0.9, 0.3], roughness=0.4) {
374
+ for (a = [0:5]) {
375
+ rotate([0, 0, a * 60 + 30])
376
+ translate([0.65, 0, 0.1])
377
+ sphere(r=0.05);
378
+ rotate([0, 0, a * 60])
379
+ translate([0.5, 0, -0.4])
380
+ sphere(r=0.045);
381
+ }
382
+ }
383
+ `,
384
+ );
385
+
386
+ // Tier 3: Grape
387
+ writeFile(
388
+ "assets/models/fruit_3.scad",
389
+ `$fn = 28;
390
+ $asa = 45;
391
+ r = 0.98;
392
+
393
+ color([0.48, 0.12, 0.68], roughness=0.2) {
394
+ sphere(r=r);
395
+ }
396
+
397
+ color([0.3, 0.65, 0.2], roughness=0.6) {
398
+ translate([0, 0, r + 0.15])
399
+ cylinder(r=0.07, h=0.35, center=true);
400
+ translate([0.22, 0, r + 0.15])
401
+ rotate([15, -20, 30])
402
+ cube([0.35, 0.25, 0.04], center=true);
403
+ }
404
+ `,
405
+ );
406
+
407
+ // Tier 4: Orange / Tangerine
408
+ writeFile(
409
+ "assets/models/fruit_4.scad",
410
+ `$fn = 28;
411
+ $asa = 45;
412
+ r = 1.3;
413
+
414
+ color([1.0, 0.48, 0.02], roughness=0.45) {
415
+ scale([1.0, 1.0, 0.92])
416
+ sphere(r=r);
417
+ }
418
+
419
+ color([0.2, 0.55, 0.15], roughness=0.6) {
420
+ translate([0, 0, r * 0.9])
421
+ cylinder(r1=0.15, r2=0.06, h=0.18, center=true);
422
+ translate([0.28, 0.1, r * 0.9 + 0.1])
423
+ rotate([20, -15, 25])
424
+ cube([0.45, 0.22, 0.05], center=true);
425
+ }
426
+ `,
427
+ );
428
+
429
+ // Tier 5: Apple
430
+ writeFile(
431
+ "assets/models/fruit_5.scad",
432
+ `$fn = 30;
433
+ $asa = 45;
434
+ r = 1.65;
435
+
436
+ difference() {
437
+ color([0.9, 0.08, 0.12], roughness=0.15) {
438
+ scale([1.0, 1.0, 0.95])
439
+ sphere(r=r);
440
+ }
441
+ translate([0, 0, r * 0.95])
442
+ sphere(r=0.4);
443
+ translate([0, 0, -r * 0.95])
444
+ sphere(r=0.35);
445
+ }
446
+
447
+ color([0.35, 0.2, 0.1], roughness=0.8) {
448
+ translate([0.05, 0, r * 0.9])
449
+ rotate([0, 12, 0])
450
+ cylinder(r=0.07, h=0.55, center=true);
451
+ }
452
+
453
+ color([0.15, 0.7, 0.2], roughness=0.4) {
454
+ translate([0.35, 0, r * 0.95])
455
+ rotate([10, -25, 30])
456
+ cube([0.5, 0.26, 0.05], center=true);
457
+ }
458
+ `,
459
+ );
460
+
461
+ // Tier 6: Peach
462
+ writeFile(
463
+ "assets/models/fruit_6.scad",
464
+ `$fn = 30;
465
+ $asa = 45;
466
+ r = 2.05;
467
+
468
+ color([1.0, 0.42, 0.45], roughness=0.6) {
469
+ translate([-0.18, 0, 0])
470
+ scale([1.0, 0.96, 1.05])
471
+ sphere(r=r * 0.94);
472
+ translate([0.18, 0, 0])
473
+ scale([1.0, 0.96, 1.05])
474
+ sphere(r=r * 0.94);
475
+ }
476
+
477
+ color([0.2, 0.65, 0.25], roughness=0.5) {
478
+ translate([0.25, 0, r + 0.1])
479
+ rotate([15, -35, 40])
480
+ cube([0.7, 0.35, 0.06], center=true);
481
+ translate([0, 0, r + 0.05])
482
+ cylinder(r=0.08, h=0.3, center=true);
483
+ }
484
+ `,
485
+ );
486
+
487
+ // Tier 7: Melon
488
+ writeFile(
489
+ "assets/models/fruit_7.scad",
490
+ `$fn = 32;
491
+ $asa = 45;
492
+ r = 2.5;
493
+
494
+ color([0.52, 0.88, 0.42], roughness=0.35) {
495
+ sphere(r=r);
496
+ }
497
+
498
+ color([0.88, 0.98, 0.62], roughness=0.6) {
499
+ for (i = [0:5]) {
500
+ rotate([0, 0, i * 30])
501
+ rotate([90, 0, 0])
502
+ difference() {
503
+ cylinder(r=r + 0.03, h=0.18, center=true);
504
+ cylinder(r=r - 0.05, h=0.25, center=true);
505
+ }
506
+ }
507
+ }
508
+
509
+ color([0.28, 0.55, 0.2], roughness=0.7) {
510
+ translate([0, 0, r + 0.1])
511
+ cylinder(r=0.18, h=0.35, center=true);
512
+ }
513
+ `,
514
+ );
515
+
516
+ // Tier 8: Watermelon
517
+ writeFile(
518
+ "assets/models/fruit_8.scad",
519
+ `$fn = 32;
520
+ $asa = 45;
521
+ r = 3.0;
522
+
523
+ color([0.22, 0.72, 0.28], roughness=0.2) {
524
+ sphere(r=r);
525
+ }
526
+
527
+ color([0.06, 0.28, 0.1], roughness=0.35) {
528
+ for (a = [0:7]) {
529
+ rotate([0, 0, a * 45])
530
+ rotate([0, 18, 0])
531
+ difference() {
532
+ sphere(r=r + 0.02);
533
+ sphere(r=r - 0.05);
534
+ cube([r * 3, r * 1.5, r * 3], center=true);
535
+ }
536
+ }
537
+ }
538
+
539
+ color([0.2, 0.45, 0.15], roughness=0.6) {
540
+ translate([0, 0, r + 0.15])
541
+ cylinder(r1=0.25, r2=0.12, h=0.45, center=true);
542
+ }
543
+ `,
544
+ );
545
+
546
+ // Tier 9: Celestial King Watermelon
547
+ writeFile(
548
+ "assets/models/fruit_9.scad",
549
+ `$fn = 32;
550
+ $asa = 45;
551
+ r = 3.6;
552
+
553
+ color([1.0, 0.78, 0.15], emissive=[0.9, 0.55, 0.05], emissiveIntensity=1.5, metalness=0.4, roughness=0.2) {
554
+ sphere(r=r);
555
+ }
556
+
557
+ color([1.0, 0.85, 0.25], metalness=0.9, roughness=0.2) {
558
+ translate([0, 0, r + 0.35]) {
559
+ cylinder(r=1.2, h=0.3, center=true);
560
+ for (i = [0:5]) {
561
+ rotate([0, 0, i * 60])
562
+ translate([1.05, 0, 0.45])
563
+ cylinder(r1=0.22, r2=0.03, h=0.7, center=true);
564
+ }
565
+ }
566
+ }
567
+
568
+ color([0.2, 0.9, 1.0], emissive=[0.3, 0.9, 1.0], emissiveIntensity=3.0, roughness=0.1) {
569
+ for (i = [0:5]) {
570
+ rotate([0, 0, i * 60 + 30])
571
+ translate([1.1, 0, r + 0.45])
572
+ sphere(r=0.15);
573
+ }
574
+ translate([0, 0, r + 1.2])
575
+ sphere(r=0.28);
576
+ }
577
+ `,
578
+ );
579
+
580
+ // ==========================================
581
+ // 3. GODOT 4 SCRIPTS & DATA
582
+ // ==========================================
583
+
584
+ writeFile(
585
+ "project.godot",
586
+ `config_version=5
587
+
588
+ [application]
589
+
590
+ config/name="Fruit Fusion 3D"
591
+ config/description="3D Merge Game in Godot 4"
592
+ run/main_scene="res://scenes/main.tscn"
593
+ config/features=PackedStringArray("4.3", "Forward Plus")
594
+
595
+ [autoload]
596
+
597
+ AudioManager="*res://scripts/audio_manager.gd"
598
+
599
+ [display]
600
+
601
+ window/size/viewport_width=720
602
+ window/size/viewport_height=1080
603
+ window/size/mode=0
604
+ window/size/resizable=true
605
+ window/stretch/mode="canvas_items"
606
+ window/stretch/aspect="expand"
607
+ window/handheld/orientation=1
608
+
609
+ [editor_plugins]
610
+
611
+ enabled=PackedStringArray("res://addons/scad_importer/plugin.cfg")
612
+
613
+ [rendering]
614
+
615
+ anti_aliasing/quality/msaa_3d=2
616
+ anti_aliasing/quality/screen_space_aa=1
617
+ lights_and_shadows/directional_shadow/soft_shadow_filter_quality=2
618
+ `,
619
+ );
620
+
621
+ writeFile(
622
+ ".gitignore",
623
+ `.godot/
624
+ *.tmp
625
+ *.log
626
+ `,
627
+ );
628
+
629
+ writeFile(
630
+ "scripts/audio_manager.gd",
631
+ `extends Node
632
+
633
+ var sample_rate: float = 22050.0
634
+
635
+ func _ready():
636
+ process_mode = Node.PROCESS_MODE_ALWAYS
637
+
638
+ func play_pop_sound(pitch_mult: float = 1.0):
639
+ _generate_and_play_sfx(350.0 * pitch_mult, 700.0 * pitch_mult, 0.12, 0.4, "sine")
640
+
641
+ func play_merge_sound(tier: int):
642
+ var base_freq = 240.0 + (tier * 75.0)
643
+ _generate_and_play_sfx(base_freq, base_freq * 1.5, 0.22, 0.55, "triangle")
644
+
645
+ func play_drop_sound():
646
+ _generate_and_play_sfx(180.0, 110.0, 0.09, 0.35, "sine")
647
+
648
+ func play_game_over_sound():
649
+ _generate_and_play_sfx(300.0, 120.0, 0.6, 0.6, "saw")
650
+
651
+ func _generate_and_play_sfx(start_freq: float, end_freq: float, duration: float, volume: float, wave_type: String):
652
+ var player = AudioStreamPlayer.new()
653
+ add_child(player)
654
+
655
+ var total_frames = int(sample_rate * duration)
656
+ var byte_data = PackedByteArray()
657
+
658
+ for i in range(total_frames):
659
+ var t = float(i) / float(total_frames)
660
+ var current_freq = lerp(start_freq, end_freq, t)
661
+ var phase = float(i) * current_freq * TAU / sample_rate
662
+
663
+ var sample: float = 0.0
664
+ if wave_type == "sine":
665
+ sample = sin(phase)
666
+ elif wave_type == "triangle":
667
+ sample = asin(sin(phase)) * (2.0 / PI)
668
+ elif wave_type == "saw":
669
+ sample = (fposmod(phase, TAU) / PI) - 1.0
670
+
671
+ var envelope = sin(t * PI * 0.5) * exp(-t * 4.5) * volume
672
+ sample *= envelope
673
+
674
+ var int_val = int(clamp(sample, -1.0, 1.0) * 32767.0)
675
+ byte_data.append(int_val & 0xFF)
676
+ byte_data.append((int_val >> 8) & 0xFF)
677
+
678
+ var stream = AudioStreamWAV.new()
679
+ stream.format = AudioStreamWAV.FORMAT_16_BITS
680
+ stream.mix_rate = int(sample_rate)
681
+ stream.stereo = false
682
+ stream.data = byte_data
683
+
684
+ player.stream = stream
685
+ player.play()
686
+ player.finished.connect(player.queue_free)
687
+ `,
688
+ );
689
+
690
+ writeFile(
691
+ "scripts/fruit_data.gd",
692
+ `class_name FruitData
693
+ extends RefCounted
694
+
695
+ const FRUIT_NAMES = [
696
+ "Cherry",
697
+ "Strawberry",
698
+ "Grape",
699
+ "Tangerine",
700
+ "Apple",
701
+ "Peach",
702
+ "Melon",
703
+ "Watermelon",
704
+ "King Sun"
705
+ ]
706
+
707
+ const FRUIT_RADII = [
708
+ 0.55,
709
+ 0.75,
710
+ 0.98,
711
+ 1.30,
712
+ 1.65,
713
+ 2.05,
714
+ 2.50,
715
+ 3.00,
716
+ 3.60
717
+ ]
718
+
719
+ const FRUIT_SCORES = [
720
+ 2,
721
+ 4,
722
+ 8,
723
+ 16,
724
+ 32,
725
+ 64,
726
+ 128,
727
+ 256,
728
+ 512
729
+ ]
730
+
731
+ const FRUIT_COLORS = [
732
+ Color(0.85, 0.05, 0.15),
733
+ Color(0.95, 0.12, 0.28),
734
+ Color(0.55, 0.15, 0.75),
735
+ Color(1.00, 0.50, 0.05),
736
+ Color(0.90, 0.10, 0.12),
737
+ Color(1.00, 0.55, 0.50),
738
+ Color(0.55, 0.88, 0.45),
739
+ Color(0.18, 0.65, 0.25),
740
+ Color(1.00, 0.82, 0.15)
741
+ ]
742
+
743
+ static func get_model_path(tier: int) -> String:
744
+ return "res://assets/models/fruit_%d.scad" % clampi(tier, 1, 9)
745
+ `,
746
+ );
747
+
748
+ writeFile(
749
+ "scripts/fruit.gd",
750
+ `class_name Fruit
751
+ extends RigidBody3D
752
+
753
+ signal fruit_merged(pos: Vector3, next_tier: int, score: int)
754
+ signal settled_in_danger_zone(fruit_node: Fruit)
755
+
756
+ @export var tier: int = 1
757
+
758
+ var radius: float = 0.55
759
+ var is_merging: bool = false
760
+ var has_dropped: bool = false
761
+ var drop_time: float = 0.0
762
+ var model_instance: Node3D = null
763
+
764
+ func setup(p_tier: int):
765
+ tier = clampi(p_tier, 1, 9)
766
+ radius = FruitData.FRUIT_RADII[tier - 1]
767
+ mass = pow(radius, 2.5) * 1.5
768
+
769
+ for child in get_children():
770
+ if child is CollisionShape3D or child is Node3D:
771
+ child.queue_free()
772
+
773
+ var col = CollisionShape3D.new()
774
+ var sphere = SphereShape3D.new()
775
+ sphere.radius = radius
776
+ col.shape = sphere
777
+ add_child(col)
778
+
779
+ var model_path = FruitData.get_model_path(tier)
780
+ if ResourceLoader.exists(model_path):
781
+ var model_scene = load(model_path)
782
+ if model_scene:
783
+ model_instance = model_scene.instantiate()
784
+ add_child(model_instance)
785
+
786
+ if not model_instance:
787
+ var fallback_mesh = MeshInstance3D.new()
788
+ var s_mesh = SphereMesh.new()
789
+ s_mesh.radius = radius
790
+ s_mesh.height = radius * 2.0
791
+ fallback_mesh.mesh = s_mesh
792
+ var mat = StandardMaterial3D.new()
793
+ mat.albedo_color = FruitData.FRUIT_COLORS[tier - 1]
794
+ mat.roughness = 0.3
795
+ fallback_mesh.material_override = mat
796
+ add_child(fallback_mesh)
797
+
798
+ func _ready():
799
+ axis_lock_linear_z = true
800
+ axis_lock_angular_x = true
801
+ axis_lock_angular_y = true
802
+
803
+ contact_monitor = true
804
+ max_contacts_reported = 4
805
+ body_entered.connect(_on_body_entered)
806
+
807
+ scale = Vector3.ONE * 0.2
808
+ var tween = create_tween().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
809
+ tween.tween_property(self, "scale", Vector3.ONE, 0.25)
810
+
811
+ func _process(delta):
812
+ if has_dropped:
813
+ drop_time += delta
814
+ if drop_time > 2.5 and global_position.y >= 13.5 and linear_velocity.length() < 0.6:
815
+ settled_in_danger_zone.emit(self)
816
+
817
+ func _on_body_entered(body: Node):
818
+ if is_merging:
819
+ return
820
+
821
+ if body is Fruit:
822
+ var other: Fruit = body
823
+ if other.is_merging:
824
+ return
825
+
826
+ if other.tier == self.tier and self.tier < 9:
827
+ if self.get_instance_id() < other.get_instance_id():
828
+ self.is_merging = true
829
+ other.is_merging = true
830
+ var merge_pos = (global_position + other.global_position) * 0.5
831
+ fruit_merged.emit(merge_pos, self.tier + 1, FruitData.FRUIT_SCORES[self.tier - 1])
832
+ other.queue_free()
833
+ self.queue_free()
834
+ `,
835
+ );
836
+
837
+ writeFile(
838
+ "scripts/game_manager.gd",
839
+ `class_name GameManager
840
+ extends Node3D
841
+
842
+ signal score_updated(score: int, high_score: int)
843
+ signal next_fruit_changed(tier: int)
844
+ signal game_over_triggered
845
+
846
+ @onready var dropper: Node3D = $Dropper
847
+ @onready var fruits_container: Node3D = $FruitsContainer
848
+ @onready var aim_line: MeshInstance3D = $AimLine
849
+ @onready var container_visual: Node3D = $ContainerVisual
850
+
851
+ const BOX_HALF_WIDTH: float = 4.8
852
+ const DROP_HEIGHT: float = 14.8
853
+
854
+ var current_score: int = 0
855
+ var high_score: int = 0
856
+ var next_tier: int = 1
857
+ var active_preview_tier: int = 1
858
+ var preview_fruit_node: Node3D = null
859
+
860
+ var can_drop: bool = true
861
+ var drop_cooldown: float = 0.55
862
+ var is_game_over: bool = false
863
+ var danger_timer: float = 0.0
864
+ const DANGER_THRESHOLD: float = 2.8
865
+
866
+ var high_score_file = "user://fusion_highscore.save"
867
+
868
+ func _ready():
869
+ load_high_score()
870
+ score_updated.emit(current_score, high_score)
871
+
872
+ _setup_container_visuals()
873
+ _setup_dropper_visuals()
874
+
875
+ randomize()
876
+ active_preview_tier = randi_range(1, 3)
877
+ next_tier = randi_range(1, 3)
878
+ next_fruit_changed.emit(next_tier)
879
+
880
+ _spawn_preview_fruit()
881
+ _update_aim_line()
882
+
883
+ func _setup_container_visuals():
884
+ if not is_instance_valid(container_visual):
885
+ return
886
+
887
+ var loaded_scad = false
888
+ var container_model_path = "res://assets/models/container.scad"
889
+ if ResourceLoader.exists(container_model_path):
890
+ var container_scene = load(container_model_path)
891
+ if container_scene:
892
+ var c_inst = container_scene.instantiate()
893
+ container_visual.add_child(c_inst)
894
+ _enforce_glass_transparency(c_inst)
895
+ loaded_scad = true
896
+
897
+ if not loaded_scad:
898
+ _build_procedural_glass_box()
899
+
900
+ func _enforce_glass_transparency(root_node: Node):
901
+ for child in root_node.get_children():
902
+ if child is MeshInstance3D:
903
+ var mesh = child.mesh
904
+ if mesh:
905
+ for s in range(mesh.get_surface_count()):
906
+ var mat = child.get_active_material(s)
907
+ if mat is BaseMaterial3D:
908
+ # If it is a glass material (light tint or alpha transparency)
909
+ if mat.albedo_color.a < 0.95 or mat.albedo_color.b > 0.85:
910
+ var glass = mat.duplicate()
911
+ glass.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
912
+ glass.cull_mode = BaseMaterial3D.CULL_BACK
913
+ glass.albedo_color = Color(0.85, 0.95, 1.0, 0.12)
914
+ glass.roughness = 0.05
915
+ glass.metallic = 0.1
916
+ child.set_surface_override_material(s, glass)
917
+ _enforce_glass_transparency(child)
918
+
919
+ func _build_procedural_glass_box():
920
+ # Procedural transparent glass box with visible frames
921
+ var glass_mat = StandardMaterial3D.new()
922
+ glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
923
+ glass_mat.cull_mode = BaseMaterial3D.CULL_BACK
924
+ glass_mat.albedo_color = Color(0.85, 0.95, 1.0, 0.12)
925
+ glass_mat.roughness = 0.05
926
+ glass_mat.metallic = 0.05
927
+
928
+ var frame_mat = StandardMaterial3D.new()
929
+ frame_mat.albedo_color = Color(0.8, 0.85, 0.9, 1.0)
930
+ frame_mat.metallic = 0.85
931
+ frame_mat.roughness = 0.25
932
+
933
+ # Back Glass
934
+ var back_mesh = MeshInstance3D.new()
935
+ var bm = BoxMesh.new()
936
+ bm.size = Vector3(12.0, 16.0, 0.1)
937
+ back_mesh.mesh = bm
938
+ back_mesh.material_override = glass_mat
939
+ back_mesh.position = Vector3(0, 8.0, -2.0)
940
+ container_visual.add_child(back_mesh)
941
+
942
+ # Left Glass
943
+ var left_mesh = MeshInstance3D.new()
944
+ var lm = BoxMesh.new()
945
+ lm.size = Vector3(0.1, 16.0, 4.0)
946
+ left_mesh.mesh = lm
947
+ left_mesh.material_override = glass_mat
948
+ left_mesh.position = Vector3(-6.0, 8.0, 0)
949
+ container_visual.add_child(left_mesh)
950
+
951
+ # Right Glass
952
+ var right_mesh = MeshInstance3D.new()
953
+ var rm = BoxMesh.new()
954
+ rm.size = Vector3(0.1, 16.0, 4.0)
955
+ right_mesh.mesh = rm
956
+ right_mesh.material_override = glass_mat
957
+ right_mesh.position = Vector3(6.0, 8.0, 0)
958
+ container_visual.add_child(right_mesh)
959
+
960
+ # Front Ultra-clear Glass
961
+ var front_mat = glass_mat.duplicate()
962
+ front_mat.albedo_color = Color(0.9, 0.97, 1.0, 0.07)
963
+ var front_mesh = MeshInstance3D.new()
964
+ var fm = BoxMesh.new()
965
+ fm.size = Vector3(12.0, 16.0, 0.05)
966
+ front_mesh.mesh = fm
967
+ front_mesh.material_override = front_mat
968
+ front_mesh.position = Vector3(0, 8.0, 2.0)
969
+ container_visual.add_child(front_mesh)
970
+
971
+ # Wooden Base
972
+ var base_mesh = MeshInstance3D.new()
973
+ var basem = BoxMesh.new()
974
+ basem.size = Vector3(13.6, 0.8, 5.6)
975
+ base_mesh.mesh = basem
976
+ var wood_mat = StandardMaterial3D.new()
977
+ wood_mat.albedo_color = Color(0.55, 0.32, 0.16)
978
+ wood_mat.roughness = 0.7
979
+ base_mesh.material_override = wood_mat
980
+ base_mesh.position = Vector3(0, -0.4, 0)
981
+ container_visual.add_child(base_mesh)
982
+
983
+ # Danger Line Marker at Y = 13.5
984
+ var danger_mat = StandardMaterial3D.new()
985
+ danger_mat.albedo_color = Color(1.0, 0.2, 0.2, 1.0)
986
+ danger_mat.emission_enabled = true
987
+ danger_mat.emission = Color(1.0, 0.2, 0.2)
988
+ danger_mat.emission_energy_multiplier = 2.5
989
+ var danger_line = MeshInstance3D.new()
990
+ var dlm = BoxMesh.new()
991
+ dlm.size = Vector3(12.4, 0.12, 4.4)
992
+ danger_line.mesh = dlm
993
+ danger_line.material_override = danger_mat
994
+ danger_line.position = Vector3(0, 13.5, 0)
995
+ container_visual.add_child(danger_line)
996
+
997
+ func _setup_dropper_visuals():
998
+ var dropper_model_path = "res://assets/models/dropper.scad"
999
+ if ResourceLoader.exists(dropper_model_path) and is_instance_valid(dropper):
1000
+ var dropper_scene = load(dropper_model_path)
1001
+ if dropper_scene:
1002
+ if dropper.has_node("CloudMesh"):
1003
+ dropper.get_node("CloudMesh").visible = false
1004
+ if dropper.has_node("PointerMesh"):
1005
+ dropper.get_node("PointerMesh").visible = false
1006
+ var d_inst = dropper_scene.instantiate()
1007
+ dropper.add_child(d_inst)
1008
+
1009
+ func load_high_score():
1010
+ if FileAccess.file_exists(high_score_file):
1011
+ var file = FileAccess.open(high_score_file, FileAccess.READ)
1012
+ if file:
1013
+ high_score = file.get_32()
1014
+ file.close()
1015
+
1016
+ func save_high_score():
1017
+ var file = FileAccess.open(high_score_file, FileAccess.WRITE)
1018
+ if file:
1019
+ file.store_32(high_score)
1020
+ file.close()
1021
+
1022
+ func _unhandled_input(event: InputEvent):
1023
+ if is_game_over or not is_instance_valid(dropper):
1024
+ return
1025
+
1026
+ if event is InputEventMouseMotion:
1027
+ var vp_size = get_viewport().get_visible_rect().size
1028
+ if vp_size.x > 0.0:
1029
+ var norm_x = (event.position.x / vp_size.x) * 2.0 - 1.0
1030
+ var target_x = clamp(norm_x * (BOX_HALF_WIDTH + 0.5), -BOX_HALF_WIDTH, BOX_HALF_WIDTH)
1031
+ dropper.position.x = target_x
1032
+ _update_aim_line()
1033
+
1034
+ if event.is_action_pressed("ui_accept") or (event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.is_pressed()):
1035
+ try_drop_fruit()
1036
+
1037
+ func _physics_process(delta: float):
1038
+ if not is_inside_tree() or not is_instance_valid(fruits_container) or not is_instance_valid(dropper):
1039
+ return
1040
+
1041
+ if is_game_over:
1042
+ return
1043
+
1044
+ var move_dir = Input.get_axis("ui_left", "ui_right")
1045
+ if move_dir != 0.0:
1046
+ dropper.position.x = clamp(dropper.position.x + move_dir * 14.0 * delta, -BOX_HALF_WIDTH, BOX_HALF_WIDTH)
1047
+ _update_aim_line()
1048
+
1049
+ var fruits_in_danger = 0
1050
+ for fruit in fruits_container.get_children():
1051
+ if is_instance_valid(fruit) and fruit is Fruit and fruit.has_dropped:
1052
+ if fruit.global_position.y >= 13.5 and fruit.drop_time > 2.0 and fruit.linear_velocity.length() < 0.5:
1053
+ fruits_in_danger += 1
1054
+
1055
+ if fruits_in_danger > 0:
1056
+ danger_timer += delta
1057
+ if danger_timer >= DANGER_THRESHOLD:
1058
+ trigger_game_over()
1059
+ else:
1060
+ danger_timer = max(0.0, danger_timer - delta * 1.5)
1061
+
1062
+ func _update_aim_line():
1063
+ if is_instance_valid(aim_line) and is_instance_valid(dropper):
1064
+ aim_line.position.x = dropper.position.x
1065
+ aim_line.position.y = DROP_HEIGHT * 0.5
1066
+ aim_line.scale.y = DROP_HEIGHT
1067
+
1068
+ func _spawn_preview_fruit():
1069
+ if not is_instance_valid(dropper):
1070
+ return
1071
+
1072
+ if is_instance_valid(preview_fruit_node):
1073
+ preview_fruit_node.queue_free()
1074
+ preview_fruit_node = null
1075
+
1076
+ var model_path = FruitData.get_model_path(active_preview_tier)
1077
+ if ResourceLoader.exists(model_path):
1078
+ var scene = load(model_path)
1079
+ if scene:
1080
+ preview_fruit_node = scene.instantiate()
1081
+ dropper.add_child(preview_fruit_node)
1082
+ preview_fruit_node.position = Vector3(0, -0.9, 0)
1083
+
1084
+ if not preview_fruit_node:
1085
+ var mesh_inst = MeshInstance3D.new()
1086
+ var sm = SphereMesh.new()
1087
+ sm.radius = FruitData.FRUIT_RADII[active_preview_tier - 1]
1088
+ sm.height = sm.radius * 2.0
1089
+ mesh_inst.mesh = sm
1090
+ var mat = StandardMaterial3D.new()
1091
+ mat.albedo_color = FruitData.FRUIT_COLORS[active_preview_tier - 1]
1092
+ mesh_inst.material_override = mat
1093
+ preview_fruit_node = mesh_inst
1094
+ dropper.add_child(preview_fruit_node)
1095
+ preview_fruit_node.position = Vector3(0, -0.9, 0)
1096
+
1097
+ func try_drop_fruit():
1098
+ if not can_drop or is_game_over or not is_instance_valid(fruits_container) or not is_instance_valid(dropper):
1099
+ return
1100
+
1101
+ can_drop = false
1102
+ AudioManager.play_drop_sound()
1103
+
1104
+ var fruit = Fruit.new()
1105
+ fruits_container.add_child(fruit)
1106
+ fruit.setup(active_preview_tier)
1107
+ fruit.position = dropper.position + Vector3(0, -0.9, 0)
1108
+ fruit.has_dropped = true
1109
+ fruit.fruit_merged.connect(_on_fruit_merged)
1110
+
1111
+ if is_instance_valid(preview_fruit_node):
1112
+ preview_fruit_node.queue_free()
1113
+ preview_fruit_node = null
1114
+
1115
+ active_preview_tier = next_tier
1116
+ next_tier = randi_range(1, 3)
1117
+ next_fruit_changed.emit(next_tier)
1118
+
1119
+ get_tree().create_timer(drop_cooldown).timeout.connect(func():
1120
+ if not is_game_over:
1121
+ can_drop = true
1122
+ _spawn_preview_fruit()
1123
+ )
1124
+
1125
+ func _on_fruit_merged(merge_pos: Vector3, new_tier: int, score_awarded: int):
1126
+ current_score += score_awarded
1127
+ if current_score > high_score:
1128
+ high_score = current_score
1129
+ save_high_score()
1130
+ score_updated.emit(current_score, high_score)
1131
+
1132
+ AudioManager.play_merge_sound(new_tier)
1133
+ _spawn_merge_vfx(merge_pos, FruitData.FRUIT_COLORS[new_tier - 2])
1134
+
1135
+ if not is_instance_valid(fruits_container):
1136
+ return
1137
+
1138
+ var evolved_fruit = Fruit.new()
1139
+ fruits_container.add_child(evolved_fruit)
1140
+ evolved_fruit.setup(new_tier)
1141
+ evolved_fruit.position = merge_pos
1142
+ evolved_fruit.has_dropped = true
1143
+ evolved_fruit.drop_time = 1.0
1144
+ evolved_fruit.fruit_merged.connect(_on_fruit_merged)
1145
+
1146
+ func _spawn_merge_vfx(pos: Vector3, color: Color):
1147
+ var particles = CPUParticles3D.new()
1148
+ add_child(particles)
1149
+ particles.position = pos
1150
+ particles.emitting = true
1151
+ particles.one_shot = true
1152
+ particles.explosiveness = 0.95
1153
+ particles.lifetime = 0.45
1154
+ particles.amount = 24
1155
+ particles.spread = 180.0
1156
+ particles.initial_velocity_min = 4.0
1157
+ particles.initial_velocity_max = 8.0
1158
+ particles.scale_amount_min = 0.15
1159
+ particles.scale_amount_max = 0.35
1160
+ particles.color = color
1161
+
1162
+ var mesh = BoxMesh.new()
1163
+ mesh.size = Vector3(0.2, 0.2, 0.2)
1164
+ particles.mesh = mesh
1165
+
1166
+ get_tree().create_timer(0.55).timeout.connect(particles.queue_free)
1167
+
1168
+ func trigger_game_over():
1169
+ if is_game_over:
1170
+ return
1171
+ is_game_over = true
1172
+ AudioManager.play_game_over_sound()
1173
+ game_over_triggered.emit()
1174
+
1175
+ func restart_game():
1176
+ if is_instance_valid(fruits_container):
1177
+ for child in fruits_container.get_children():
1178
+ child.queue_free()
1179
+ current_score = 0
1180
+ danger_timer = 0.0
1181
+ is_game_over = false
1182
+ can_drop = true
1183
+ score_updated.emit(current_score, high_score)
1184
+ active_preview_tier = randi_range(1, 3)
1185
+ next_tier = randi_range(1, 3)
1186
+ next_fruit_changed.emit(next_tier)
1187
+ _spawn_preview_fruit()
1188
+ `,
1189
+ );
1190
+
1191
+ writeFile(
1192
+ "scripts/ui_manager.gd",
1193
+ `extends Control
1194
+
1195
+ @onready var game_manager: GameManager = $"../"
1196
+ @onready var score_label = $ScoreContainer/ScoreValue
1197
+ @onready var high_score_label = $ScoreContainer/HighScoreValue
1198
+ @onready var next_fruit_preview = $NextContainer/PreviewRect
1199
+ @onready var next_fruit_label = $NextContainer/NextFruitName
1200
+ @onready var game_over_panel = $GameOverPanel
1201
+ @onready var final_score_label = $GameOverPanel/FinalScore
1202
+ @onready var restart_button = $GameOverPanel/RestartButton
1203
+ @onready var danger_indicator = $DangerIndicator
1204
+
1205
+ var danger_blink_time: float = 0.0
1206
+
1207
+ func _ready():
1208
+ game_over_panel.visible = false
1209
+ danger_indicator.visible = false
1210
+ restart_button.pressed.connect(_on_restart_pressed)
1211
+ game_manager.score_updated.connect(_on_score_updated)
1212
+ game_manager.next_fruit_changed.connect(_on_next_fruit_changed)
1213
+ game_manager.game_over_triggered.connect(_on_game_over)
1214
+
1215
+ func _process(delta):
1216
+ if is_instance_valid(game_manager) and game_manager.danger_timer > 0.5 and not game_manager.is_game_over:
1217
+ danger_indicator.visible = true
1218
+ danger_blink_time += delta * 6.0
1219
+ danger_indicator.modulate.a = (sin(danger_blink_time) * 0.5 + 0.5) * 0.85
1220
+ else:
1221
+ danger_indicator.visible = false
1222
+ danger_blink_time = 0.0
1223
+
1224
+ func _on_score_updated(score: int, high_score: int):
1225
+ score_label.text = str(score)
1226
+ high_score_label.text = "BEST: " + str(high_score)
1227
+
1228
+ func _on_next_fruit_changed(tier: int):
1229
+ var fruit_name = FruitData.FRUIT_NAMES[tier - 1]
1230
+ next_fruit_label.text = fruit_name
1231
+ next_fruit_preview.color = FruitData.FRUIT_COLORS[tier - 1]
1232
+
1233
+ func _on_game_over():
1234
+ game_over_panel.visible = true
1235
+ final_score_label.text = "FINAL SCORE: %d" % game_manager.current_score
1236
+
1237
+ func _on_restart_pressed():
1238
+ game_over_panel.visible = false
1239
+ game_manager.restart_game()
1240
+ `,
1241
+ );
1242
+
1243
+ // ==========================================
1244
+ // 4. MAIN SCENE FILE (.tscn)
1245
+ // ==========================================
1246
+
1247
+ writeFile(
1248
+ "scenes/main.tscn",
1249
+ `[gd_scene load_steps=15 format=3 uid="uid://c2suika0main"]
1250
+
1251
+ [ext_resource type="Script" path="res://scripts/game_manager.gd" id="1_gm"]
1252
+ [ext_resource type="Script" path="res://scripts/ui_manager.gd" id="2_ui"]
1253
+
1254
+ [sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_sky"]
1255
+ sky_top_color = Color(0.2, 0.45, 0.75, 1)
1256
+ sky_horizon_color = Color(0.65, 0.78, 0.88, 1)
1257
+ ground_bottom_color = Color(0.12, 0.16, 0.22, 1)
1258
+ ground_horizon_color = Color(0.65, 0.78, 0.88, 1)
1259
+
1260
+ [sub_resource type="Sky" id="Sky_env"]
1261
+ sky_material = SubResource("ProceduralSkyMaterial_sky")
1262
+
1263
+ [sub_resource type="Environment" id="Environment_main"]
1264
+ background_mode = 2
1265
+ sky = SubResource("Sky_env")
1266
+ ambient_light_source = 2
1267
+ ambient_light_color = Color(0.4, 0.4, 0.45, 1)
1268
+ tonemap_mode = 2
1269
+ glow_enabled = true
1270
+ glow_intensity = 0.4
1271
+ glow_bloom = 0.15
1272
+
1273
+ [sub_resource type="BoxShape3D" id="BoxShape3D_bottom"]
1274
+ size = Vector3(14, 0.5, 6)
1275
+
1276
+ [sub_resource type="BoxShape3D" id="BoxShape3D_wall"]
1277
+ size = Vector3(0.5, 18, 6)
1278
+
1279
+ [sub_resource type="BoxShape3D" id="BoxShape3D_back"]
1280
+ size = Vector3(14, 18, 0.5)
1281
+
1282
+ [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_aim"]
1283
+ transparency = 1
1284
+ albedo_color = Color(1, 0.9, 0.3, 0.35)
1285
+ emission_enabled = true
1286
+ emission = Color(1, 0.85, 0.2, 1)
1287
+ emission_energy_multiplier = 0.8
1288
+
1289
+ [sub_resource type="CylinderMesh" id="CylinderMesh_aim"]
1290
+ material = SubResource("StandardMaterial3D_aim")
1291
+ top_radius = 0.04
1292
+ bottom_radius = 0.04
1293
+ height = 1.0
1294
+
1295
+ [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_cloud"]
1296
+ albedo_color = Color(0.95, 0.96, 1, 1)
1297
+ roughness = 0.4
1298
+
1299
+ [sub_resource type="SphereMesh" id="SphereMesh_cloud"]
1300
+ material = SubResource("StandardMaterial3D_cloud")
1301
+ radius = 0.8
1302
+ height = 1.6
1303
+
1304
+ [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_pointer"]
1305
+ albedo_color = Color(1, 0.8, 0.1, 1)
1306
+ emission_enabled = true
1307
+ emission = Color(1, 0.7, 0, 1)
1308
+ emission_energy_multiplier = 2.0
1309
+
1310
+ [sub_resource type="CylinderMesh" id="CylinderMesh_pointer"]
1311
+ material = SubResource("StandardMaterial3D_pointer")
1312
+ top_radius = 0.02
1313
+ bottom_radius = 0.22
1314
+ height = 0.45
1315
+
1316
+ [node name="Main" type="Node3D"]
1317
+ script = ExtResource("1_gm")
1318
+
1319
+ [node name="WorldEnvironment" type="WorldEnvironment" parent="."]
1320
+ environment = SubResource("Environment_main")
1321
+
1322
+ [node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
1323
+ transform = Transform3D(0.866, -0.353, 0.353, 0, 0.707, 0.707, -0.5, -0.612, 0.612, 5, 20, 15)
1324
+ light_color = Color(1, 0.98, 0.94, 1)
1325
+ light_energy = 1.2
1326
+ shadow_enabled = true
1327
+
1328
+ [node name="FillLight" type="DirectionalLight3D" parent="."]
1329
+ transform = Transform3D(-0.866, 0.25, -0.433, 0, 0.866, 0.5, 0.5, 0.433, -0.75, -5, 10, -10)
1330
+ light_color = Color(0.5, 0.7, 0.9, 1)
1331
+ light_energy = 0.4
1332
+
1333
+ [node name="Camera3D" type="Camera3D" parent="."]
1334
+ transform = Transform3D(1, 0, 0, 0, 0.996, 0.087, 0, -0.087, 0.996, 0, 8.5, 21.5)
1335
+ fov = 48.0
1336
+
1337
+ [node name="ContainerPhysics" type="StaticBody3D" parent="."]
1338
+
1339
+ [node name="CollisionBottom" type="CollisionShape3D" parent="ContainerPhysics"]
1340
+ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
1341
+ shape = SubResource("BoxShape3D_bottom")
1342
+
1343
+ [node name="CollisionLeft" type="CollisionShape3D" parent="ContainerPhysics"]
1344
+ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6.2, 8, 0)
1345
+ shape = SubResource("BoxShape3D_wall")
1346
+
1347
+ [node name="CollisionRight" type="CollisionShape3D" parent="ContainerPhysics"]
1348
+ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6.2, 8, 0)
1349
+ shape = SubResource("BoxShape3D_wall")
1350
+
1351
+ [node name="CollisionBack" type="CollisionShape3D" parent="ContainerPhysics"]
1352
+ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 8, -2.2)
1353
+ shape = SubResource("BoxShape3D_back")
1354
+
1355
+ [node name="CollisionFront" type="CollisionShape3D" parent="ContainerPhysics"]
1356
+ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 8, 2.2)
1357
+ shape = SubResource("BoxShape3D_back")
1358
+
1359
+ [node name="ContainerVisual" type="Node3D" parent="."]
1360
+
1361
+ [node name="FruitsContainer" type="Node3D" parent="."]
1362
+
1363
+ [node name="AimLine" type="MeshInstance3D" parent="."]
1364
+ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 7.5, 0)
1365
+ mesh = SubResource("CylinderMesh_aim")
1366
+
1367
+ [node name="Dropper" type="Node3D" parent="."]
1368
+ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 15.2, 0)
1369
+
1370
+ [node name="CloudMesh" type="MeshInstance3D" parent="Dropper"]
1371
+ mesh = SubResource("SphereMesh_cloud")
1372
+
1373
+ [node name="PointerMesh" type="MeshInstance3D" parent="Dropper"]
1374
+ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.9, 0)
1375
+ mesh = SubResource("CylinderMesh_pointer")
1376
+
1377
+ [node name="UI" type="Control" parent="."]
1378
+ layout_mode = 3
1379
+ anchors_preset = 15
1380
+ anchor_right = 1.0
1381
+ anchor_bottom = 1.0
1382
+ grow_horizontal = 2
1383
+ grow_vertical = 2
1384
+ mouse_filter = 2
1385
+ script = ExtResource("2_ui")
1386
+
1387
+ [node name="ScoreContainer" type="VBoxContainer" parent="UI"]
1388
+ layout_mode = 0
1389
+ offset_left = 30.0
1390
+ offset_top = 25.0
1391
+ offset_right = 260.0
1392
+ offset_bottom = 110.0
1393
+
1394
+ [node name="ScoreLabel" type="Label" parent="UI/ScoreContainer"]
1395
+ layout_mode = 2
1396
+ theme_override_font_sizes/font_size = 18
1397
+ theme_override_colors/font_color = Color(0.85, 0.9, 1, 0.8)
1398
+ text = "SCORE"
1399
+
1400
+ [node name="ScoreValue" type="Label" parent="UI/ScoreContainer"]
1401
+ layout_mode = 2
1402
+ theme_override_font_sizes/font_size = 40
1403
+ theme_override_colors/font_color = Color(1, 1, 1, 1)
1404
+ text = "0"
1405
+
1406
+ [node name="HighScoreValue" type="Label" parent="UI/ScoreContainer"]
1407
+ layout_mode = 2
1408
+ theme_override_font_sizes/font_size = 16
1409
+ theme_override_colors/font_color = Color(1, 0.85, 0.3, 0.9)
1410
+ text = "BEST: 0"
1411
+
1412
+ [node name="NextContainer" type="VBoxContainer" parent="UI"]
1413
+ layout_mode = 1
1414
+ anchors_preset = 1
1415
+ anchor_left = 1.0
1416
+ anchor_right = 1.0
1417
+ offset_left = -150.0
1418
+ offset_top = 25.0
1419
+ offset_right = -30.0
1420
+ offset_bottom = 135.0
1421
+ grow_horizontal = 0
1422
+ alignment = 1
1423
+
1424
+ [node name="NextTitle" type="Label" parent="UI/NextContainer"]
1425
+ layout_mode = 2
1426
+ theme_override_font_sizes/font_size = 16
1427
+ theme_override_colors/font_color = Color(0.85, 0.9, 1, 0.8)
1428
+ text = "NEXT"
1429
+ horizontal_alignment = 1
1430
+
1431
+ [node name="PreviewRect" type="ColorRect" parent="UI/NextContainer"]
1432
+ custom_minimum_size = Vector2(48, 48)
1433
+ layout_mode = 2
1434
+ color = Color(0.95, 0.12, 0.28, 1)
1435
+
1436
+ [node name="NextFruitName" type="Label" parent="UI/NextContainer"]
1437
+ layout_mode = 2
1438
+ theme_override_font_sizes/font_size = 14
1439
+ theme_override_colors/font_color = Color(1, 1, 1, 0.9)
1440
+ text = "Strawberry"
1441
+ horizontal_alignment = 1
1442
+
1443
+ [node name="EvolutionGuide" type="HBoxContainer" parent="UI"]
1444
+ layout_mode = 1
1445
+ anchors_preset = 12
1446
+ anchor_top = 1.0
1447
+ anchor_right = 1.0
1448
+ anchor_bottom = 1.0
1449
+ offset_top = -55.0
1450
+ offset_bottom = -15.0
1451
+ grow_horizontal = 2
1452
+ grow_vertical = 0
1453
+ alignment = 1
1454
+
1455
+ [node name="GuideLabel" type="Label" parent="UI/EvolutionGuide"]
1456
+ layout_mode = 2
1457
+ theme_override_font_sizes/font_size = 15
1458
+ theme_override_colors/font_color = Color(1, 1, 1, 0.75)
1459
+ text = "🍒 Cherry → 🍓 Strawberry → 🍇 Grape → 🍊 Orange → 🍎 Apple → 🍑 Peach → 🍈 Melon → 🍉 Watermelon → 👑 Sun"
1460
+
1461
+ [node name="DangerIndicator" type="ColorRect" parent="UI"]
1462
+ visible = false
1463
+ layout_mode = 1
1464
+ anchors_preset = 10
1465
+ anchor_right = 1.0
1466
+ offset_top = 145.0
1467
+ offset_bottom = 155.0
1468
+ grow_horizontal = 2
1469
+ color = Color(1, 0.15, 0.15, 0.6)
1470
+
1471
+ [node name="GameOverPanel" type="Panel" parent="UI"]
1472
+ visible = false
1473
+ layout_mode = 1
1474
+ anchors_preset = 8
1475
+ anchor_left = 0.5
1476
+ anchor_top = 0.5
1477
+ anchor_right = 0.5
1478
+ anchor_bottom = 0.5
1479
+ offset_left = -170.0
1480
+ offset_top = -140.0
1481
+ offset_right = 170.0
1482
+ offset_bottom = 140.0
1483
+ grow_horizontal = 2
1484
+ grow_vertical = 2
1485
+
1486
+ [node name="Title" type="Label" parent="UI/GameOverPanel"]
1487
+ layout_mode = 1
1488
+ anchors_preset = 10
1489
+ anchor_right = 1.0
1490
+ offset_top = 25.0
1491
+ offset_bottom = 65.0
1492
+ grow_horizontal = 2
1493
+ theme_override_font_sizes/font_size = 28
1494
+ theme_override_colors/font_color = Color(1, 0.3, 0.3, 1)
1495
+ text = "GAME OVER"
1496
+ horizontal_alignment = 1
1497
+
1498
+ [node name="FinalScore" type="Label" parent="UI/GameOverPanel"]
1499
+ layout_mode = 1
1500
+ anchors_preset = 10
1501
+ anchor_right = 1.0
1502
+ offset_top = 80.0
1503
+ offset_bottom = 120.0
1504
+ grow_horizontal = 2
1505
+ theme_override_font_sizes/font_size = 22
1506
+ text = "FINAL SCORE: 0"
1507
+ horizontal_alignment = 1
1508
+
1509
+ [node name="RestartButton" type="Button" parent="UI/GameOverPanel"]
1510
+ layout_mode = 1
1511
+ anchors_preset = 7
1512
+ anchor_left = 0.5
1513
+ anchor_top = 1.0
1514
+ anchor_right = 0.5
1515
+ anchor_bottom = 1.0
1516
+ offset_left = -90.0
1517
+ offset_top = -75.0
1518
+ offset_right = 90.0
1519
+ offset_bottom = -25.0
1520
+ grow_horizontal = 2
1521
+ grow_vertical = 0
1522
+ theme_override_font_sizes/font_size = 20
1523
+ text = "PLAY AGAIN"
1524
+ `,
1525
+ );
1526
+
1527
+ // ==========================================
1528
+ // 5. DOCUMENTATION (README.md)
1529
+ // ==========================================
1530
+
1531
+ writeFile(
1532
+ "README.md",
1533
+ `# Fruit Fusion 3D (Suika Game Mechanic in Godot 4)
1534
+
1535
+ A popular merge puzzle game inspired by the Watermelon / Suika Game, implemented in **Godot 4** with procedural **OpenSCAD 3D models** and PBR materials.
1536
+
1537
+ ## Game Overview
1538
+ Drop different fruits into the transparent glass container. When two identical fruits collide, they merge with a satisfying pop and evolve into a larger, higher-tier fruit!
1539
+
1540
+ ### Evolution Chain:
1541
+ 1. 🍒 **Cherry**
1542
+ 2. 🍓 **Strawberry**
1543
+ 3. 🍇 **Grape**
1544
+ 4. 🍊 **Tangerine / Orange**
1545
+ 5. 🍎 **Apple**
1546
+ 6. 🍑 **Peach**
1547
+ 7. 🍈 **Melon**
1548
+ 8. 🍉 **Watermelon**
1549
+ 9. 👑 **King Sun** (Ultimate crowned glowing celestial watermelon)
1550
+
1551
+ ---
1552
+
1553
+ ## Controls
1554
+ - **Move Dropper / Aim**:
1555
+ - Mouse Move / Touch Drag
1556
+ - Left / Right Arrow keys
1557
+ - A / D keys
1558
+ - **Drop Fruit**:
1559
+ - Left Click
1560
+ - Spacebar
1561
+ - Enter
1562
+ - Down Arrow
1563
+ `,
1564
+ );
1565
+
1566
+ console.log(`
1567
+ =====================================================
1568
+ Setup complete! Project created in ./${PROJECT_DIR}
1569
+ To run the game:
1570
+ 1. Open Godot 4
1571
+ 2. Import the project located at ./${PROJECT_DIR}/project.godot
1572
+ 3. Run the project (F5)
1573
+ =====================================================
1574
+ `);