feat: update

This commit is contained in:
2026-06-11 18:28:25 +08:00
parent 8520f9db3c
commit 8a2fb36a98
65 changed files with 4407 additions and 572 deletions
@@ -130,11 +130,31 @@ class AsyncRequest:
else:
return type.create(ns, data.get(result_key))
func _resume_conn(p_err : int):
func _socket_exception_from_error(p_error) -> NakamaException:
if p_error is NakamaException:
return p_error
if typeof(p_error) == TYPE_INT:
var message := "Socket connection failed"
match p_error:
ERR_TIMEOUT:
message = "Socket connection timed out"
ERR_CANT_CONNECT:
message = "Socket connection refused or closed"
ERR_CANT_RESOLVE:
message = "Socket host could not be resolved"
ERR_CONNECTION_ERROR:
message = "Socket connection error"
ERR_UNAVAILABLE:
message = "Socket connection unavailable"
return NakamaException.new(message, p_error)
return NakamaException.new(str(p_error), -1)
func _resume_conn(p_error = OK):
if _conn:
if p_err: # Exception
logger.warning("Connection error: %d" % p_err)
_conn.resume(NakamaAsyncResult.new(NakamaException.new()))
if p_error != OK:
var exception := _socket_exception_from_error(p_error)
logger.warning("Connection error: %s" % exception.message)
_conn.resume(NakamaAsyncResult.new(exception))
else:
logger.info("Connected!")
_conn.resume(NakamaAsyncResult.new())
@@ -16,7 +16,7 @@
[ext_resource type="BoxMesh" uid="uid://b5cc3prem52r6" path="res://addons/enhanced_gridmap/meshlibrary/tile_freeze.tres" id="11_pgnbl"]
[ext_resource type="BoxMesh" uid="uid://dcjdwbffgtutt" path="res://addons/enhanced_gridmap/meshlibrary/tile_non_walkable.tres" id="11_uwjsj"]
[ext_resource type="Texture2D" uid="uid://cdnxwlysxnujd" path="res://assets/textures/tile_heart.png" id="12_heart_tex"]
[ext_resource type="BoxMesh" uid="uid://bj2bvvno3cqh2" path="res://addons/enhanced_gridmap/meshlibrary/tile_sticky.tres" id="13_sticky"]
[ext_resource type="BoxMesh" uid="uid://cqv6ebaxhasr3" path="res://addons/enhanced_gridmap/meshlibrary/tile_sticky.tres" id="13_sticky"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_uxput"]
resource_name = "boost"
@@ -1,4 +1,4 @@
[gd_resource type="BoxMesh" format=3 uid="uid://bj2bvvno3cqh2"]
[gd_resource type="BoxMesh" format=3 uid="uid://cqv6ebaxhasr3"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_sticky"]
transparency = 1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dhw5mg25iw5dr"
path="res://.godot/imported/dasher_getting_hit.glb-c78f6332496d820acfe217a7dee1a171.scn"
[deps]
source_file="res://assets/characters/dashers/dasher_getting_hit.glb"
dest_files=["res://.godot/imported/dasher_getting_hit.glb-c78f6332496d820acfe217a7dee1a171.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://du6f10iq0vup5"
path="res://.godot/imported/dasher_hit.glb-30828368c8a512ea3a0f74180da77e8d.scn"
[deps]
source_file="res://assets/characters/dashers/dasher_hit.glb"
dest_files=["res://.godot/imported/dasher_hit.glb-30828368c8a512ea3a0f74180da77e8d.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://c4noulpnr0sxc"
path="res://.godot/imported/dasher_hold.glb-91cd82b87dfb263f0f79ba3e311e0190.scn"
[deps]
source_file="res://assets/characters/dashers/dasher_hold.glb"
dest_files=["res://.godot/imported/dasher_hold.glb-91cd82b87dfb263f0f79ba3e311e0190.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://c1ymy6xseihds"
path="res://.godot/imported/dasher_put.glb-6349d495a8444cf30de4860b525f36c7.scn"
[deps]
source_file="res://assets/characters/dashers/dasher_put.glb"
dest_files=["res://.godot/imported/dasher_put.glb-6349d495a8444cf30de4860b525f36c7.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://3ipo6a5j5s48"
path="res://.godot/imported/dasher_stun.glb-5c67aab95f2516134af8dc025fb37bf5.scn"
[deps]
source_file="res://assets/characters/dashers/dasher_stun.glb"
dest_files=["res://.godot/imported/dasher_stun.glb-5c67aab95f2516134af8dc025fb37bf5.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://jqfmoyxrxuk6"
path="res://.godot/imported/dasher_take.glb-855d3406fda9c42eaacac6fd49155c0f.scn"
[deps]
source_file="res://assets/characters/dashers/dasher_take.glb"
dest_files=["res://.godot/imported/dasher_take.glb-855d3406fda9c42eaacac6fd49155c0f.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bt8pfg1j14lq4"
path="res://.godot/imported/tekton_hold.glb-200d797f4b202164003717fecbdfba97.scn"
[deps]
source_file="res://assets/characters/dashers/tekton_hold.glb"
dest_files=["res://.godot/imported/tekton_hold.glb-200d797f4b202164003717fecbdfba97.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bkhla0bd8gh6k"
path="res://.godot/imported/tekton_put.glb-10897df056830be9e27ad171553e71b3.scn"
[deps]
source_file="res://assets/characters/dashers/tekton_put.glb"
dest_files=["res://.godot/imported/tekton_put.glb-10897df056830be9e27ad171553e71b3.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ci2lcm0tf02s0"
path="res://.godot/imported/tekton_take.glb-e5326a2ca2848c1e748b4be0ea916233.scn"
[deps]
source_file="res://assets/characters/dashers/tekton_take.glb"
dest_files=["res://.godot/imported/tekton_take.glb-e5326a2ca2848c1e748b4be0ea916233.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cuporokvsp4ml"
path="res://.godot/imported/tekton_fishing_animation.glb-4469ef86e01e801d40365a39e33a43d9.scn"
[deps]
source_file="res://assets/characters/tektons/tekton_fishing_animation.glb"
dest_files=["res://.godot/imported/tekton_fishing_animation.glb-4469ef86e01e801d40365a39e33a43d9.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

@@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://csjf5vwqc7kjc"
path.s3tc="res://.godot/imported/tekton_fishing_animation_Ted_tex.png-843bfeb42afe9f0cc8bbef67a1660342.s3tc.ctex"
path.etc2="res://.godot/imported/tekton_fishing_animation_Ted_tex.png-843bfeb42afe9f0cc8bbef67a1660342.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "ad130a61dfc420142fb25fb3f8aa3c6f"
}
[deps]
source_file="res://assets/characters/tektons/tekton_fishing_animation_Ted_tex.png"
dest_files=["res://.godot/imported/tekton_fishing_animation_Ted_tex.png-843bfeb42afe9f0cc8bbef67a1660342.s3tc.ctex", "res://.godot/imported/tekton_fishing_animation_Ted_tex.png-843bfeb42afe9f0cc8bbef67a1660342.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

@@ -0,0 +1,42 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cyb7umn1uk7j8"
path.s3tc="res://.godot/imported/Block_box_tex.png-3a9361e801fe4eacf19d5d43c9246e12.s3tc.ctex"
path.etc2="res://.godot/imported/Block_box_tex.png-3a9361e801fe4eacf19d5d43c9246e12.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
[deps]
source_file="res://assets/models/props/Block_box_tex.png"
dest_files=["res://.godot/imported/Block_box_tex.png-3a9361e801fe4eacf19d5d43c9246e12.s3tc.ctex", "res://.godot/imported/Block_box_tex.png-3a9361e801fe4eacf19d5d43c9246e12.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.
+543
View File
@@ -0,0 +1,543 @@
{
"asset":{
"generator":"Khronos glTF Blender I/O v4.3.47",
"version":"2.0"
},
"scene":0,
"scenes":[
{
"name":"Scene",
"nodes":[
4
]
}
],
"nodes":[
{
"name":"Bone.002",
"rotation":[
0,
1.1920928955078125e-07,
0,
1
],
"translation":[
0,
0.7185347676277161,
0
]
},
{
"children":[
0
],
"name":"Bone.001",
"translation":[
0,
0.7113578915596008,
0
]
},
{
"children":[
1
],
"name":"Bone"
},
{
"mesh":0,
"name":"wall_body",
"skin":0
},
{
"children":[
3,
2
],
"name":"Wall"
}
],
"animations":[
{
"channels":[
{
"sampler":0,
"target":{
"node":2,
"path":"translation"
}
},
{
"sampler":1,
"target":{
"node":2,
"path":"rotation"
}
},
{
"sampler":2,
"target":{
"node":2,
"path":"scale"
}
},
{
"sampler":3,
"target":{
"node":1,
"path":"translation"
}
},
{
"sampler":4,
"target":{
"node":1,
"path":"rotation"
}
},
{
"sampler":5,
"target":{
"node":1,
"path":"scale"
}
},
{
"sampler":6,
"target":{
"node":0,
"path":"translation"
}
},
{
"sampler":7,
"target":{
"node":0,
"path":"rotation"
}
},
{
"sampler":8,
"target":{
"node":0,
"path":"scale"
}
},
{
"sampler":9,
"target":{
"node":4,
"path":"translation"
}
},
{
"sampler":10,
"target":{
"node":4,
"path":"rotation"
}
},
{
"sampler":11,
"target":{
"node":4,
"path":"scale"
}
}
],
"name":"Wall.001|Wall Animation|Anima_Layer",
"samplers":[
{
"input":7,
"interpolation":"LINEAR",
"output":8
},
{
"input":9,
"interpolation":"STEP",
"output":10
},
{
"input":7,
"interpolation":"LINEAR",
"output":11
},
{
"input":9,
"interpolation":"LINEAR",
"output":12
},
{
"input":9,
"interpolation":"STEP",
"output":13
},
{
"input":7,
"interpolation":"LINEAR",
"output":14
},
{
"input":9,
"interpolation":"LINEAR",
"output":15
},
{
"input":9,
"interpolation":"STEP",
"output":16
},
{
"input":7,
"interpolation":"LINEAR",
"output":17
},
{
"input":9,
"interpolation":"STEP",
"output":18
},
{
"input":9,
"interpolation":"STEP",
"output":19
},
{
"input":9,
"interpolation":"STEP",
"output":20
}
]
}
],
"materials":[
{
"doubleSided":true,
"name":"Material",
"pbrMetallicRoughness":{
"baseColorTexture":{
"index":0
},
"metallicFactor":0,
"roughnessFactor":0.5
}
}
],
"meshes":[
{
"name":"Cube",
"primitives":[
{
"attributes":{
"POSITION":0,
"NORMAL":1,
"TEXCOORD_0":2,
"JOINTS_0":3,
"WEIGHTS_0":4
},
"indices":5,
"material":0
}
]
}
],
"textures":[
{
"sampler":0,
"source":0
}
],
"images":[
{
"mimeType":"image/png",
"name":"Block_box_tex",
"uri":"Block_box_tex.png"
}
],
"skins":[
{
"inverseBindMatrices":6,
"joints":[
2,
1,
0
],
"name":"Wall"
}
],
"accessors":[
{
"bufferView":0,
"componentType":5126,
"count":553,
"max":[
1,
2.0071864128112793,
1
],
"min":[
-1,
0.007186145056039095,
-1.0000003576278687
],
"type":"VEC3"
},
{
"bufferView":1,
"componentType":5126,
"count":553,
"type":"VEC3"
},
{
"bufferView":2,
"componentType":5126,
"count":553,
"type":"VEC2"
},
{
"bufferView":3,
"componentType":5121,
"count":553,
"type":"VEC4"
},
{
"bufferView":4,
"componentType":5126,
"count":553,
"type":"VEC4"
},
{
"bufferView":5,
"componentType":5123,
"count":2952,
"type":"SCALAR"
},
{
"bufferView":6,
"componentType":5126,
"count":3,
"type":"MAT4"
},
{
"bufferView":7,
"componentType":5126,
"count":15,
"max":[
0.5
],
"min":[
0.03333333333333333
],
"type":"SCALAR"
},
{
"bufferView":8,
"componentType":5126,
"count":15,
"type":"VEC3"
},
{
"bufferView":9,
"componentType":5126,
"count":2,
"max":[
0.5
],
"min":[
0.03333333333333333
],
"type":"SCALAR"
},
{
"bufferView":10,
"componentType":5126,
"count":2,
"type":"VEC4"
},
{
"bufferView":11,
"componentType":5126,
"count":15,
"type":"VEC3"
},
{
"bufferView":12,
"componentType":5126,
"count":2,
"type":"VEC3"
},
{
"bufferView":13,
"componentType":5126,
"count":2,
"type":"VEC4"
},
{
"bufferView":14,
"componentType":5126,
"count":15,
"type":"VEC3"
},
{
"bufferView":15,
"componentType":5126,
"count":2,
"type":"VEC3"
},
{
"bufferView":16,
"componentType":5126,
"count":2,
"type":"VEC4"
},
{
"bufferView":17,
"componentType":5126,
"count":15,
"type":"VEC3"
},
{
"bufferView":18,
"componentType":5126,
"count":2,
"type":"VEC3"
},
{
"bufferView":19,
"componentType":5126,
"count":2,
"type":"VEC4"
},
{
"bufferView":20,
"componentType":5126,
"count":2,
"type":"VEC3"
}
],
"bufferViews":[
{
"buffer":0,
"byteLength":6636,
"byteOffset":0,
"target":34962
},
{
"buffer":0,
"byteLength":6636,
"byteOffset":6636,
"target":34962
},
{
"buffer":0,
"byteLength":4424,
"byteOffset":13272,
"target":34962
},
{
"buffer":0,
"byteLength":2212,
"byteOffset":17696,
"target":34962
},
{
"buffer":0,
"byteLength":8848,
"byteOffset":19908,
"target":34962
},
{
"buffer":0,
"byteLength":5904,
"byteOffset":28756,
"target":34963
},
{
"buffer":0,
"byteLength":192,
"byteOffset":34660
},
{
"buffer":0,
"byteLength":60,
"byteOffset":34852
},
{
"buffer":0,
"byteLength":180,
"byteOffset":34912
},
{
"buffer":0,
"byteLength":8,
"byteOffset":35092
},
{
"buffer":0,
"byteLength":32,
"byteOffset":35100
},
{
"buffer":0,
"byteLength":180,
"byteOffset":35132
},
{
"buffer":0,
"byteLength":24,
"byteOffset":35312
},
{
"buffer":0,
"byteLength":32,
"byteOffset":35336
},
{
"buffer":0,
"byteLength":180,
"byteOffset":35368
},
{
"buffer":0,
"byteLength":24,
"byteOffset":35548
},
{
"buffer":0,
"byteLength":32,
"byteOffset":35572
},
{
"buffer":0,
"byteLength":180,
"byteOffset":35604
},
{
"buffer":0,
"byteLength":24,
"byteOffset":35784
},
{
"buffer":0,
"byteLength":32,
"byteOffset":35808
},
{
"buffer":0,
"byteLength":24,
"byteOffset":35840
}
],
"samplers":[
{
"magFilter":9729,
"minFilter":9987
}
],
"buffers":[
{
"byteLength":35864,
"uri":"Box_block_2.bin"
}
]
}
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b6xusldnaa288"
path="res://.godot/imported/Box_block_2.gltf-6b278c07bb52956b8b62d9b428e8f6a6.scn"
[deps]
source_file="res://assets/models/props/Box_block_2.gltf"
dest_files=["res://.godot/imported/Box_block_2.gltf-6b278c07bb52956b8b62d9b428e8f6a6.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.
@@ -0,0 +1,44 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bh5epppot37nh"
path="res://.godot/imported/wall_animation.fbx-22f993a05720796858e5daa12a9ef4c5.scn"
[deps]
source_file="res://assets/models/props/wall_animation.fbx"
dest_files=["res://.godot/imported/wall_animation.fbx-22f993a05720796858e5daa12a9ef4c5.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=true
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dahuvrhr1u74y"
path="res://.godot/imported/wheat_block_0.glb-35fd252da9f0a42b79ed5759815c731c.scn"
[deps]
source_file="res://assets/models/props/wheat_block_0.glb"
dest_files=["res://.godot/imported/wheat_block_0.glb-35fd252da9f0a42b79ed5759815c731c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cdfu3bvdiisg2"
path.s3tc="res://.godot/imported/wheat_block_0_wheat_tex.png-c6a087ae50523310eace371eb3353908.s3tc.ctex"
path.etc2="res://.godot/imported/wheat_block_0_wheat_tex.png-c6a087ae50523310eace371eb3353908.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "448396071c54a15f593f0eb2628889c1"
}
[deps]
source_file="res://assets/models/props/wheat_block_0_wheat_tex.png"
dest_files=["res://.godot/imported/wheat_block_0_wheat_tex.png-c6a087ae50523310eace371eb3353908.s3tc.ctex", "res://.godot/imported/wheat_block_0_wheat_tex.png-c6a087ae50523310eace371eb3353908.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ddjg2x57kr6"
path="res://.godot/imported/wheat_block_1.glb-5d996ad9d4004837507f80c51b730927.scn"
[deps]
source_file="res://assets/models/props/wheat_block_1.glb"
dest_files=["res://.godot/imported/wheat_block_1.glb-5d996ad9d4004837507f80c51b730927.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://blluf0gwi8nbi"
path.s3tc="res://.godot/imported/wheat_block_1_wheat_tex.png-0784d9deebdc1ba07043c2e1e3363717.s3tc.ctex"
path.etc2="res://.godot/imported/wheat_block_1_wheat_tex.png-0784d9deebdc1ba07043c2e1e3363717.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "448396071c54a15f593f0eb2628889c1"
}
[deps]
source_file="res://assets/models/props/wheat_block_1_wheat_tex.png"
dest_files=["res://.godot/imported/wheat_block_1_wheat_tex.png-0784d9deebdc1ba07043c2e1e3363717.s3tc.ctex", "res://.godot/imported/wheat_block_1_wheat_tex.png-0784d9deebdc1ba07043c2e1e3363717.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
+687 -285
View File
@@ -1,4 +1,4 @@
# Candy Cannon Survival (Gauntlet) — Technical Implementation Plan
# Candy Pump Survival (Gauntlet) — Technical Implementation Plan
## 1. Feasibility Summary
@@ -8,21 +8,33 @@
| GDD Feature | Existing System | Reuse Level | New Work |
|---|---|---|---|
| Game Mode registration | `GameMode.gd` enum + `LobbyManager` | **Direct** | Add enum entry + strings |
| 20×20 Arena setup | `StopNGoManager._setup_arena()` pattern | **Heavy** | Custom layout, same GridMap API |
| Game Mode registration | `GameMode.gd` enum + `LobbyManager` | **Direct** | Already registered (`GAUNTLET = 3`) |
| 24×24 Arena setup | `StopNGoManager._setup_arena()` pattern | **Heavy** | Custom layout, same GridMap API |
| Tile collection / scoring | `GoalsCycleManager` | **Direct** | Reuse goal completion + scoring |
| Mission system (goals) | `GoalManager` + `goals_cycle_manager.gd` | **Direct** | Same 3×3 pattern matching |
| Timed match (3 min) | `GoalsCycleManager.start_match()` | **Direct** | Pass 180s duration |
| Player movement | `PlayerMovementManager` | **Direct** | No changes |
| Powerup system | `SpecialTilesManager` | **Partial** | Cleanser is a new powerup type |
| Attack/Push mechanic | `PlayerMovementManager.try_push()` | **Adapt** | Smack = modified push with new rules |
| NPC (Candy Cannon) | `tekton.gd` + `TektonController` | **Pattern** | New NPC, reuses projectile/animation patterns |
| Player movement | `PlayerMovementManager` | **Direct** | Add sticky checks to `simple_move_to()` |
| Sticky cells | `StopNGoManager` safe zone overlay (Layer 2) | **Pattern** | New tile type, same GridMap layer approach |
| Telegraph VFX | `VFXManager` / `animation.gd` | **Pattern** | New animations, same system |
| Telegraph VFX | Existing GauntletManager telegraph system | **Direct** | Adapt for growth ticks instead of cannon |
| Smack mechanic | Existing GauntletManager smack system | **Direct** | Already implemented |
| Cleanser power-up | Existing GauntletManager cleanser system | **Direct** | Already implemented |
| HUD | `StopNGoManager._setup_hud()` pattern | **Direct** | Mode-specific labels |
| Network sync | RPC patterns throughout codebase | **Direct** | Same `rpc()` / `sync_*` patterns |
| Lobby settings | `LobbyManager` signal/sync pattern | **Direct** | Add gauntlet-specific settings |
| Bot AI | `BotController` + `BotStrategicPlanner` | **Adapt** | New strategy for cannon avoidance |
| Bot AI | `BotController` + `BotStrategicPlanner` | **Adapt** | New strategy for sticky avoidance |
| Candy bubbles | **NEW** | **New** | Bubble spawn, grow, explode system |
| Candidate scoring | **NEW** | **New** | Cellular-automation growth algorithm |
| Movement buffers | **NEW** | **New** | Hidden safe zone detection/decay |
### What Changes from Current Implementation
The current `GauntletManager` uses a **cannon shooting** model (NPC fires projectiles at targets). The new GDD replaces this with a **ground growth** model (candy spreads from the ground via cellular-automation scoring). This requires:
1. **Remove** `_fire_volley()`, cannon timer, volley size, projectile spawning
2. **Add** growth tick timer, candidate scoring, weighted cell selection
3. **Add** candy bubble system (spawn, grow, explode)
4. **Add** movement buffer detection and decay
5. **Add** layer-based priority logic
6. **Change** arena from 20×20 to 24×24
---
@@ -30,19 +42,21 @@
```
main.gd
├── _init_managers() ← Add GauntletManager instantiation (same as StopNGoManager pattern)
├── _setup_host_game() ← Add gauntlet arena setup branch
├── _start_game() ← Add gauntlet start_game_mode() call
├── _init_managers() ← GauntletManager instantiation (existing)
├── _setup_host_game() ← GauntletManager._setup_arena()
├── _start_game() ← GauntletManager.start_game_mode()
GauntletManager (NEW)
├── _setup_arena() ← 20×20 grid, center 3×3 NPC zone
GauntletManager (MODIFY EXISTING)
├── _setup_arena() ← 24×24 grid, center 3×3 NPC zone
├── _setup_hud() ← Mission label, cleanser indicator
├── start_game_mode() ← Start cannon timer, spawn tiles
├── _process() ← Cannon volley timer, phase escalation
├── CandyCannonController ← Targeting logic, volley fire
├── start_game_mode() ← Start growth timer, spawn tiles
├── _process() ← Growth tick timer, bubble timer, phase escalation
├── GrowthTick system ← Candidate scoring, weighted selection, telegraph
├── CandyBubble system ← Bubble spawn, grow, explode
├── StickyCell system ← Layer 2 overlay, trap logic
├── Cleanser system ← New powerup unlocked via missions
├── Smack system Modified push with charge/cooldown
├── MovementBuffer system ← Hidden safe zone detection, decay, camping override
├── Cleanser system ← Existing powerup
├── Smack system ← Existing modified push
└── Win condition ← Highest score at timer end
```
@@ -50,342 +64,730 @@ GauntletManager (NEW)
## 3. File-by-File Implementation
### 3.1 Game Mode Registration
### 3.1 Game Mode Registration — Already Done
#### `scripts/game_mode.gd`
The existing `game_mode.gd` already has:
```gdscript
enum Mode {
FREEMODE = 0,
STOP_N_GO = 1,
TEKTON_DOORS = 2,
GAUNTLET = 3 # NEW
GAUNTLET = 3 # Already registered
}
# Add to from_string(), mode_to_string(), get_all_modes(), is_restricted()
```
#### `scripts/managers/lobby_manager.gd`
- Add `"Candy Cannon Survival"` to `available_game_modes`
- Add `_update_available_areas()` entry → `"Gauntlet Arena"`
- Add gauntlet-specific lobby settings (mirroring Stop N Go pattern):
- `gauntlet_round_duration: int = 180`
- `gauntlet_cannon_interval: int = 5`
- `gauntlet_volley_size: int = 5`
- Corresponding `set_gauntlet_*()`, `sync_gauntlet_*()` RPCs
- Corresponding signals
And `LobbyManager` already has `"Candy Cannon Survival"` in `available_game_modes`. The mode name string can remain as-is or be updated to `"Candy Pump Survival"` if desired.
---
### 3.2 Core Manager — `gauntlet_manager.gd` (NEW)
### 3.2 Core Manager — `gauntlet_manager.gd` (MODIFY EXISTING)
**Location:** `scripts/managers/gauntlet_manager.gd`
**Pattern source:** `StopNGoManager` + `PortalModeManager`
**Major structural changes:**
#### Remove (cannon-based system):
```
var cannon_timer: float
var cannon_interval: float
var volley_size: int
var last_targeted_player_id: int
func _fire_volley()
func _select_targets()
func _get_near_player_target()
func _get_route_blocking_target()
func _get_random_non_sticky_target()
func _get_random_target()
```
#### Add (growth-based system):
```gdscript
class_name GauntletManager
extends Node
# Signals
signal phase_changed(phase_index: int)
signal cannon_fired(targets: Array)
signal growth_tick(targets: Array)
signal player_trapped(player_id: int)
signal cleanser_granted(player_id: int)
signal bubble_spawned(center: Vector2i)
signal bubble_exploded(center: Vector2i, area: Array[Vector2i])
# Constants
const ARENA_SIZE = 20
const ARENA_SIZE = 24
const NPC_SIZE = 3
const NPC_CENTER = Vector2i(9, 9) # Center of 20×20
const TILE_STICKY = 17 # New MeshLibrary item ID
const NPC_CENTER = Vector2i(11, 11) # Center of 24×24
const TILE_STICKY = 17
const TILE_TELEGRAPH = 18
const TILE_WALKABLE = 0
const TILE_OBSTACLE = 4
# Phase timing
enum Phase { OPEN_ARENA, ROUTE_PRESSURE, SURVIVAL_ENDGAME }
var current_phase: Phase = Phase.OPEN_ARENA
enum Phase { OUTER_PRESSURE, MIDDLE_PRESSURE, INNER_SURVIVAL }
var current_phase: Phase = Phase.OUTER_PRESSURE
var elapsed_time: float = 0.0
# Cannon state
var cannon_timer: float = 0.0
var cannon_interval: float = 5.0
var volley_size: int = 5
var sticky_cells: Dictionary = {} # Vector2i true
var last_targeted_player_id: int = -1
# Growth tick state
var growth_timer: float = 0.0
var growth_interval: float = 3.0
var telegraph_duration: float = 1.0
var sticky_cells: Dictionary = {} # Vector2i -> true
var telegraphed_cells: Dictionary = {} # Vector2i -> true
# Smack state (per-player)
var smack_cooldowns: Dictionary = {} # player_id → float (time remaining)
var smack_charged: Dictionary = {} # player_id → float (charge window remaining)
# Cleanser tracking
var player_mission_completions: Dictionary = {} # player_id → int
var player_cleansers: Dictionary = {} # player_id → int (0 or 1)
# Trapped players
var trapped_players: Dictionary = {} # player_id → true
```
#### Key methods (mapped to existing patterns):
| Method | Pattern Source | Purpose |
|---|---|---|
| `_setup_arena()` | `StopNGoManager._setup_arena()` | 20×20 grid, center 3×3 NPC block, walkable floor |
| `_setup_hud()` | `StopNGoManager._setup_hud()` | Mission label, cleanser indicator |
| `start_game_mode()` | `StopNGoManager.start_game_mode()` | Initialize cannon, spawn tiles, activate HUD |
| `_process(delta)` | `StopNGoManager._process()` | Tick cannon timer, fire volleys, update phase |
| `_fire_volley()` | NEW (uses `tekton.gd` projectile pattern) | Select targets, telegraph, apply sticky |
| `_apply_sticky(pos)` | `StopNGoManager._spawn_dynamic_safe_zone()` (Layer 2 overlay) | Set GridMap Layer 2 to TILE_STICKY |
| `_check_player_trapped(player)` | `StopNGoManager._is_in_safe_zone()` (inverted) | Check if player is on sticky cell |
| `check_win_condition()` | `StopNGoManager.check_win_condition()` | Highest score at match end |
| `sync_phase()` RPC | `StopNGoManager.sync_phase()` | Broadcast phase to clients |
| `sync_sticky_cells()` RPC | `main.rpc("sync_grid_item")` | Sync sticky cell state |
---
### 3.3 Candy Cannon NPC — `candy_cannon_controller.gd` (NEW)
**Location:** `scripts/controllers/candy_cannon_controller.gd`
**Pattern source:** `TektonController` + `tekton.gd` projectile system
```
class_name CandyCannonController
extends Node
var gauntlet_manager: GauntletManager
var npc_center: Vector2i
var gridmap: Node
# Targeting weights per phase
var phase_weights: Array = [
# Phase 0 (Open Arena): 1×1=60%, 1×2=40%, 2×2=0%
{"1x1": 0.6, "1x2": 0.4, "2x2": 0.0},
# Phase 1 (Route Pressure): 1×1=30%, 1×2=55%, 2×2=15%
{"1x1": 0.3, "1x2": 0.55, "2x2": 0.15},
# Phase 2 (Survival): 1×1=15%, 1×2=55%, 2×2=30%
{"1x1": 0.15, "1x2": 0.55, "2x2": 0.30}
# Phase-based growth config
var phase_growth_config: Array = [
{"cells_per_tick": [4, 6], "distribution": {"outer": 0.75, "middle": 0.10, "inner": 0.00, "near_player": 0.10, "random": 0.05}},
{"cells_per_tick": [6, 8], "distribution": {"outer": 0.20, "middle": 0.50, "inner": 0.00, "near_player": 0.15, "sticky_expansion": 0.10, "random": 0.05}},
{"cells_per_tick": [8, 10], "distribution": {"outer": 0.10, "middle": 0.25, "inner": 0.35, "near_player": 0.15, "sticky_expansion": 0.15, "random": 0.10}},
]
# Candy bubble state
var bubble_timer: float = 0.0
var bubbles_this_phase: int = 0
var max_bubbles_per_phase: Array = [0, 2, 3]
var active_bubbles: Array = [] # [{center, grow_timer, warning_area}]
var recent_bubble_positions: Array = [] # For RepetitionPenalty
# Movement buffer state
var movement_buffers: Dictionary = {} # Vector2i -> {penalty: float, created_at: float}
var camping_tracker: Dictionary = {} # player_id -> {position: Vector2i, since: float}
# Smack state (per-player) — unchanged
var smack_cooldowns: Dictionary = {}
var smack_charged: Dictionary = {}
# Cleanser tracking — unchanged
var player_mission_completions: Dictionary = {}
var player_cleansers: Dictionary = {}
# Trapped players — unchanged
var trapped_players: Dictionary = {}
# Arena layer cache
var arena_layers: Dictionary = {} # Vector2i -> "outer"/"middle"/"inner"
```
**Targeting logic** reuses the `_is_position_valid()` and `get_neighbors()` from `EnhancedGridMap`, and `get_nodes_in_group("Players")` for player-proximity targeting.
**Projectile visuals** reuse `tekton.gd`'s `spawn_projectile_rpc()` pattern (arc tween from cannon → target cell).
---
### 3.4 Sticky Cell System
### 3.3 Arena Setup — `_setup_arena()`
**Approach:** Use GridMap Layer 2 (same as `StopNGoManager` safe zone overlay and `SpecialTilesManager` freeze overlay).
**Pattern source:** `StopNGoManager._setup_arena()`
**New MeshLibrary item:** `TILE_STICKY = 17` — Pink/candy-colored semi-transparent panel (same approach as TILE_SAFE = 2).
| Feature | Implementation |
|---|---|
| Visual | Layer 2 overlay with transparent candy-pink mesh |
| Movement block | `PlayerMovementManager.simple_move_to()` — add sticky check alongside wall check |
| Trap on step | `GauntletManager._check_player_on_sticky()` in `_process()` |
| Trap on push | `PlayerMovementManager.try_push()` — check landing cell for sticky |
| Cleanser pass-through | Similar to `is_invisible` wall bypass — temporary flag |
**Network sync:** Use existing `main.rpc("sync_grid_item", x, 2, z, TILE_STICKY)` — identical to how safe zones and freeze overlays sync.
---
### 3.5 Telegraph System
**Pattern source:** `StopNGoManager`'s `sync_all_safe_zones_vfx()` + `_animate_safe_zone_appear()`
1. Server selects target cells
2. `rpc("sync_telegraph", targets)` — all clients show pink glow
3. 1-second delay (Timer)
4. `rpc("sync_impact", targets)` — apply sticky, VFX, screen shake
**Visual approach:**
- Reuse Layer 2 overlay with a temporary "warning" tile ID (e.g., `TILE_TELEGRAPH = 18`)
- Animate alpha 0 → 1 over 0.8s (same `_animate_safe_zone_appear()` tween pattern)
- On impact: replace with `TILE_STICKY`, play `screen_shake_manager` via `player.rpc("trigger_screen_shake", "medium")`
---
### 3.6 Smack Mechanic
**Pattern source:** `PowerUpManager.use_special_effect()` + `PlayerMovementManager.try_push()`
The smack mechanic is a reskin of the existing Attack Mode push, with modifications:
| Property | Current Attack Mode | Gauntlet Smack |
|---|---|---|
| Charge source | `PowerUpManager.current_boost >= 100` | 8s cooldown timer (auto-refill) |
| Activation | Toggle `is_attack_mode` | 3s charged window (pink model) |
| Push distance | 3 cells backward (X=-1) | 3 cells in push direction |
| Stagger | 1.5s `apply_stagger()` | 1.0s stun |
| Sticky landing | N/A | Trapped on first sticky cell in path |
| Clash | N/A | Both stunned, no push, bars consumed |
**Implementation in GauntletManager:**
- New per-player smack state (cooldown, charged flag)
- Override or extend `PlayerMovementManager.try_push()` behavior when in gauntlet mode
- Sticky landing check: iterate push path, stop at first sticky cell → call `trap_player()`
- Clash detection: if two players activate smack within 0.5s of each other and are in range
---
### 3.7 Cleanser Power-Up
**Pattern source:** `SpecialTilesManager.inventory` system
| Property | Implementation |
|---|---|
| Unlock trigger | `GoalsCycleManager.goal_count_updated` signal — grant when `count % 2 == 0` |
| Storage | `GauntletManager.player_cleansers[peer_id] = 1` |
| Activation | New input action or existing powerup key |
| Effect | For 5 cells of movement, ignore sticky checks + clear sticky overlay on traversed cells |
| Sync | `rpc("sync_cleanser_state", peer_id, count)` |
| Clear sticky | `main.rpc("sync_grid_item", x, 2, z, -1)` — same as safe zone clear |
---
### 3.8 Candy Cannon NPC Scene — `candy_cannon.tscn` (NEW)
**Pattern source:** `tekton.tscn` + `static_tekton_stand.tscn`
- 3×3 footprint centered at `(9, 9)` in 20×20 grid
- Static body (non-movable, non-interactable)
- Animated mesh (cannon rotation, firing animation)
- No grab/throw/knock interactions (like `is_static_turret = true`)
---
### 3.9 Arena Scene — `gauntlet.tscn` (NEW) or `gauntlet.scn`
**Location:** `scenes/arena/gauntlet.tscn`
**Pattern source:** `scenes/arena/freemode.tscn`, `scenes/arena/stop_n_go.scn`
- 3D environment for the gauntlet arena
- Referenced in `main.gd._apply_arena_background()` under `"Gauntlet Arena"` match case
---
### 3.10 Integration Points in `main.gd`
Following the exact pattern of StopNGoManager / PortalModeManager:
Key changes from 20×20 to 24×24:
```gdscript
# _init_managers() — Add after portal_mode_manager block:
if LobbyManager.game_mode == "Candy Cannon Survival":
gauntlet_manager = load("res://scripts/managers/gauntlet_manager.gd").new()
gauntlet_manager.name = "GauntletManager"
add_child(gauntlet_manager)
gauntlet_manager.initialize(self, $EnhancedGridMap)
# _setup_host_game() — Add arena setup branch:
elif LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager:
gauntlet_manager._setup_arena()
# _start_game() — Add game mode start:
elif LobbyManager.game_mode == "Candy Cannon Survival":
if gauntlet_manager:
gauntlet_manager.start_game_mode()
if goals_cycle_manager:
var match_duration = LobbyManager.get_match_duration()
goals_cycle_manager.start_match(float(match_duration))
func _setup_arena():
if not multiplayer.is_server():
return
# Resize gridmap to 24×24
enhanced_gridmap.columns = ARENA_SIZE
enhanced_gridmap.rows = ARENA_SIZE
enhanced_gridmap.floors = 3
# Clear all layers
enhanced_gridmap.clear_floor(0)
enhanced_gridmap.clear_floor(1)
enhanced_gridmap.clear_floor(2)
# Fill Floor 0 with walkable tiles
for x in range(ARENA_SIZE):
for z in range(ARENA_SIZE):
enhanced_gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
# Block center 3×3 for Candy Pump NPC
for x in range(NPC_CENTER.x - 1, NPC_CENTER.x + 2):
for z in range(NPC_CENTER.y - 1, NPC_CENTER.y + 2):
enhanced_gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE)
# Build arena layer map
_build_arena_layers()
# Sync to clients
rpc("sync_arena_setup", ARENA_SIZE, NPC_CENTER)
enhanced_gridmap.initialize_astar()
enhanced_gridmap.update_astar_costs()
```
---
### 3.4 Layer Calculation — `_build_arena_layers()`
**New method.** Precomputes the layer for every cell based on edge distance.
```gdscript
func _build_arena_layers():
arena_layers.clear()
for x in range(ARENA_SIZE):
for z in range(ARENA_SIZE):
var edge_dist = mini(x, z, ARENA_SIZE - 1 - x, ARENA_SIZE - 1 - z)
var layer: String
if edge_dist <= 3:
layer = "outer"
elif edge_dist <= 7:
layer = "middle"
else:
layer = "inner"
arena_layers[Vector2i(x, z)] = layer
```
---
### 3.5 Growth Tick System — `_process_growth_tick()`
**Replaces** `_fire_volley()`. Called every 3 seconds.
```gdscript
func _process_growth_tick():
if not multiplayer.is_server():
return
var config = phase_growth_config[current_phase]
var cell_count = randi_range(config.cells_per_tick[0], config.cells_per_tick[1])
var candidates = _generate_candidates()
var selected = _select_cells_weighted(candidates, cell_count)
# Path safety check
selected = _apply_path_safety(selected)
# Movement buffer check
selected = _apply_movement_buffer_check(selected)
# Telegraph
_telegraph_cells(selected)
# After telegraph_duration: apply sticky
get_tree().create_timer(telegraph_duration).timeout.connect(func():
_apply_sticky_cells(selected)
)
```
---
### 3.6 Candidate Generation — `_generate_candidates()`
**New method.** Builds scored list of all SAFE cells.
```gdscript
func _generate_candidates() -> Array:
var candidates: Array = []
var players = get_tree().get_nodes_in_group("Players")
for x in range(ARENA_SIZE):
for z in range(ARENA_SIZE):
var pos = Vector2i(x, z)
if not _is_cell_valid_for_growth(pos):
continue
var score = _calculate_candidate_score(pos, players)
candidates.append({"pos": pos, "score": score})
return candidates
```
---
### 3.7 Candidate Scoring — `_calculate_candidate_score()`
**New method.** Implements the full Candidate Score formula from the GDD.
```gdscript
func _calculate_candidate_score(pos: Vector2i, players: Array) -> float:
var score: float = 0.0
# LayerPriority
var layer = arena_layers.get(pos, "outer")
var layer_scores = {
Phase.OUTER_PRESSURE: {"outer": 60.0, "middle": 15.0, "inner": -40.0},
Phase.MIDDLE_PRESSURE: {"outer": 20.0, "middle": 60.0, "inner": 5.0},
Phase.INNER_SURVIVAL: {"outer": 10.0, "middle": 35.0, "inner": 60.0},
}
score += layer_scores[current_phase].get(layer, 0.0)
# StickyNeighborScore (+8 per sticky neighbor, max +64)
var neighbors = _get_8_neighbors(pos)
for n in neighbors:
if sticky_cells.has(n):
score += 8.0
# InwardPressureScore
var center_dist = pos.distance_to(Vector2(NPC_CENTER))
var max_dist = Vector2(ARENA_SIZE, ARENA_SIZE).length() / 2.0
var inward_ratio = 1.0 - (center_dist / max_dist)
match current_phase:
Phase.OUTER_PRESSURE: score += lerpf(0.0, 10.0, inward_ratio)
Phase.MIDDLE_PRESSURE: score += lerpf(5.0, 20.0, inward_ratio)
Phase.INNER_SURVIVAL: score += lerpf(10.0, 30.0, inward_ratio)
# PlayerPressureScore
var min_player_dist = INF
for p in players:
var p_pos = Vector2i(p.grid_position.x, p.grid_position.z) if p.has_method("get_grid_position") else Vector2i(p.position.x, p.position.z)
var dist = pos.distance_to(p_pos)
min_player_dist = mini(min_player_dist, int(dist))
if min_player_dist >= 2 and min_player_dist <= 4:
score += 20.0
elif min_player_dist == 0:
if elapsed_time < 150.0: # Before final 30s
score -= 50.0
else:
score += 10.0
# ClusterGrowthScore
if _connects_sticky_clusters(pos):
score += 25.0
elif _expands_sticky_cluster(pos):
score += 15.0
# RoutePressureScore
if _is_high_traffic_route(pos):
score += randf_range(10.0, 25.0)
# CampingPressureScore
for pid in camping_tracker:
var camp = camping_tracker[pid]
if pos.distance_to(camp.position) <= 4:
var camp_duration = elapsed_time - camp.since
if camp_duration > 10.0 and player_cleansers.get(pid, 0) > 0:
score += 60.0
elif camp_duration > 8.0:
score += 40.0
elif camp_duration > 5.0:
score += 20.0
# RandomNoise
score += randf_range(-20.0, 20.0)
# MovementBufferPenalty
if movement_buffers.has(pos):
var buffer = movement_buffers[pos]
var penalty = _get_buffer_penalty(buffer.penalty)
score += penalty
# PathSafetyPenalty
if _would_trap_player(pos) and elapsed_time < 150.0:
score -= 100.0
elif _removes_last_exit(pos):
score -= 60.0
elif _makes_route_too_narrow(pos):
score -= 20.0
# RepetitionPenalty
if _was_recently_targeted(pos):
score -= 30.0
elif _region_targeted_repeatedly(pos):
score -= 15.0
return score
```
---
### 3.8 Weighted Cell Selection — `_select_cells_weighted()`
**New method.** Selects cells using weighted randomness from scored candidates.
```gdscript
func _select_cells_weighted(candidates: Array, count: int) -> Array[Vector2i]:
# Sort by score descending
candidates.sort_custom(func(a, b): return a.score > b.score)
# Build weight array
var weights: Array[float] = []
var total_weight: float = 0.0
for c in candidates:
var w = maxf(c.score + 100.0, 1.0) # Offset to ensure positive weights
weights.append(w)
total_weight += w
# Weighted random selection without replacement
var selected: Array[Vector2i] = []
var available = candidates.duplicate()
var available_weights = weights.duplicate()
for i in range mini(count, available.size()):
var roll = randf() * total_weight
var cumulative = 0.0
for j in range(available.size()):
cumulative += available_weights[j]
if roll <= cumulative:
selected.append(available[j].pos)
total_weight -= available_weights[j]
available.remove_at(j)
available_weights.remove_at(j)
break
return selected
```
---
### 3.9 Candy Bubble System
#### Bubble Spawn Timer
```gdscript
func _process_bubbles(delta: float):
if not multiplayer.is_server():
return
# Tick active bubbles
for i in range(active_bubbles.size() - 1, -1, -1):
var bubble = active_bubbles[i]
bubble.grow_timer -= delta
if bubble.grow_timer <= 0:
_explode_bubble(bubble)
active_bubbles.remove_at(i)
```
#### Bubble Spawn Logic
```gdscript
func _try_spawn_bubble():
var max_bubbles = max_bubbles_per_phase[current_phase]
if bubbles_this_phase >= max_bubbles:
return
var candidates = _generate_bubble_candidates()
if candidates.is_empty():
return
# Weighted selection
var selected = _select_bubble_target(candidates)
_spawn_bubble(selected)
bubbles_this_phase += 1
```
#### Bubble Candidate Scoring
```gdscript
func _generate_bubble_candidates() -> Array:
var candidates: Array = []
var players = get_tree().get_nodes_in_group("Players")
for x in range(ARENA_SIZE):
for z in range(ARENA_SIZE):
var pos = Vector2i(x, z)
if not _is_cell_valid_for_bubble(pos):
continue
var score = _calculate_bubble_score(pos, players)
candidates.append({"pos": pos, "score": score})
return candidates
func _calculate_bubble_score(pos: Vector2i, players: Array) -> float:
var score: float = 0.0
# CampingScore
for pid in camping_tracker:
var camp = camping_tracker[pid]
if pos.distance_to(camp.position) <= 4:
var camp_duration = elapsed_time - camp.since
if camp_duration > 10.0 and player_cleansers.get(pid, 0) > 0:
score += 80.0
elif camp_duration > 8.0:
score += 60.0
elif camp_duration > 5.0:
score += 40.0
# UntouchedAreaScore
if _is_near_untouched_cluster(pos):
score += 30.0
# PlayerClusterScore
var nearby_players = 0
for p in players:
var p_pos = Vector2i(p.position.x, p.position.z)
if pos.distance_to(p_pos) <= 5:
nearby_players += 1
if nearby_players >= 2:
score += 20.0
# MissionRouteScore
if _is_important_for_scoring(pos):
score += randf_range(10.0, 20.0)
# RandomNoise
score += randf_range(-20.0, 20.0)
# DirectHitPenalty
for p in players:
var p_pos = Vector2i(p.position.x, p.position.z)
if pos == p_pos:
score -= 60.0
break
# RecentBubblePenalty
for recent in recent_bubble_positions:
if pos.distance_to(recent) <= 5:
score -= 50.0
break
# UnfairTrapPenalty
if _would_create_unfair_trap(pos):
score -= 100.0
return score
```
#### Bubble Explosion
```gdscript
func _explode_bubble(bubble: Dictionary):
var center = bubble.center
var explosion_area: Array[Vector2i] = []
for dx in range(-1, 2):
for dz in range(-1, 2):
var pos = Vector2i(center.x + dx, center.y + dz)
if _is_cell_valid_for_growth(pos):
explosion_area.append(pos)
# Telegraph 3×3 area briefly, then apply sticky
_telegraph_cells(explosion_area)
get_tree().create_timer(0.5).timeout.connect(func():
_apply_sticky_cells(explosion_area)
rpc("sync_bubble_explode", center, explosion_area)
recent_bubble_positions.append(center)
if recent_bubble_positions.size() > 5:
recent_bubble_positions.remove_at(0)
)
rpc("sync_bubble_explode_vfx", center)
```
---
### 3.10 Movement Buffer System
#### Buffer Detection
```gdscript
func _detect_movement_buffers():
# Find all connected clusters of SAFE cells
var visited: Dictionary = {}
var clusters: Array = []
for x in range(ARENA_SIZE):
for z in range(ARENA_SIZE):
var pos = Vector2i(x, z)
if visited.has(pos) or not _is_cell_safe(pos):
continue
var cluster = _flood_fill_safe_cluster(pos, visited)
clusters.append(cluster)
# Apply buffer penalties to clusters that are critical for movement
for cluster in clusters:
if _is_critical_for_movement(cluster):
for pos in cluster:
if not movement_buffers.has(pos):
movement_buffers[pos] = {"penalty": 1.0, "created_at": elapsed_time}
```
#### Buffer Decay
```gdscript
func _decay_movement_buffers():
var to_remove: Array = []
for pos in movement_buffers:
var buffer = movement_buffers[pos]
# Every 5 seconds: reduce penalty by 25%
var age = elapsed_time - buffer.created_at
var decay_cycles = int(age / 5.0)
buffer.penalty *= pow(0.75, decay_cycles)
# Phase change: reduce by 50%
# (Applied once at phase transition, tracked separately)
# Final 30s: remove most
if elapsed_time > 150.0:
buffer.penalty *= 0.1
if buffer.penalty < 0.05:
to_remove.append(pos)
for pos in to_remove:
movement_buffers.erase(pos)
```
#### Camping Detection
```gdscript
func _update_camping_tracker():
var players = get_tree().get_nodes_in_group("Players")
for p in players:
var pid = p.get_multiplayer_authority()
var p_pos = Vector2i(p.position.x, p.position.z)
if camping_tracker.has(pid):
var camp = camping_tracker[pid]
if p_pos == camp.position:
pass # Still camping
else:
camping_tracker[pid] = {"position": p_pos, "since": elapsed_time}
else:
camping_tracker[pid] = {"position": p_pos, "since": elapsed_time}
```
---
### 3.11 Sticky Cell Application
```gdscript
func _apply_sticky_cells(positions: Array[Vector2i]):
for pos in positions:
if not _is_cell_valid_for_growth(pos):
continue
sticky_cells[pos] = true
telegraphed_cells.erase(pos)
# Set Layer 2 overlay
enhanced_gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY)
# Check if any player is now on sticky
_check_players_on_sticky()
# Update A* costs
enhanced_gridmap.update_astar_costs()
# Sync to clients
rpc("sync_sticky_cells", sticky_cells.keys())
```
---
### 3.12 Player Sticky Check
```gdscript
func _check_players_on_sticky():
var players = get_tree().get_nodes_in_group("Players")
for p in players:
var p_pos = Vector2i(p.position.x, p.position.z)
if sticky_cells.has(p_pos):
var pid = p.get_multiplayer_authority()
if is_cleanser_active(pid):
clear_sticky_cell(p_pos)
use_cleanser_cell(pid)
else:
_trap_player(p)
```
---
### 3.13 Path Safety Check
```gdscript
func _apply_path_safety(selected: Array[Vector2i]) -> Array[Vector2i]:
if elapsed_time > 150.0: # Final 30s: softer rules
return selected
var players = get_tree().get_nodes_in_group("Players")
var result = selected.duplicate()
for p in players:
var pid = p.get_multiplayer_authority()
if trapped_players.has(pid):
continue
var p_pos = Vector2i(p.position.x, p.position.z)
# Temporarily apply selected cells
var temp_sticky = sticky_cells.duplicate()
for pos in result:
temp_sticky[pos] = true
# Check if player has reachable safe cells within 68 cells
var has_escape = _has_reachable_safe_cell(p_pos, temp_sticky, 8)
if not has_escape:
# Replace some cells with safer alternatives
result = _replace_with_safer_candidates(result, 2)
return result
```
---
### 3.14 Telegraph System (Modified)
The existing telegraph system works but needs adaptation for growth ticks instead of cannon volleys.
```gdscript
func _telegraph_cells(positions: Array[Vector2i]):
for pos in positions:
telegraphed_cells[pos] = true
enhanced_gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
rpc("sync_growth_telegraph", positions)
# Animate telegraph
_animate_growth_telegraph(positions)
```
**Reuse existing** `_animate_telegraph()` tween pattern from current GauntletManager.
---
### 3.15 Network Sync
| Data | Sync Method | Pattern |
|---|---|---|
| Sticky cells | `rpc("sync_sticky_cells", positions)` | Same as `sync_grid_item` |
| Growth telegraph | `rpc("sync_growth_telegraph", positions)` | Same as `sync_telegraph` |
| Phase changes | `rpc("sync_gauntlet_phase", phase_idx, elapsed)` | Same as `sync_phase` |
| Bubble spawn | `rpc("sync_bubble_spawn", center, grow_duration)` | New RPC |
| Bubble explode | `rpc("sync_bubble_explode", center, area)` | New RPC |
| Trap state | `player.rpc("sync_trapped", true)` | Same as `sync_stop_freeze` |
| Cleanser grant | `rpc("sync_cleanser", peer_id, count)` | Same as `sync_goal_count` |
| Smack state | `player.rpc("sync_smack_state", charged)` | Same as `sync_modulate` |
---
### 3.16 Integration Points in `main.gd`
The existing integration in `main.gd` already handles GauntletManager. No changes needed unless the mode name string is updated.
---
## 4. New Files Summary
| File | Type | Purpose |
|---|---|---|
| `scripts/managers/gauntlet_manager.gd` | Script | Core mode logic, phases, sticky cells, cleanser, smack |
| `scripts/controllers/candy_cannon_controller.gd` | Script | Cannon targeting, volley fire, telegraph |
| `scenes/arena/gauntlet.tscn` | Scene | 3D arena environment |
| `scenes/candy_cannon.tscn` | Scene | Candy Cannon NPC (3×3, static) |
| (none) | — | All changes are modifications to existing `gauntlet_manager.gd` |
## 5. Modified Files Summary
| File | Changes |
|---|---|
| `scripts/game_mode.gd` | Add `GAUNTLET = 3` enum, string mappings |
| `scripts/managers/lobby_manager.gd` | Add mode to available list, gauntlet settings, area mapping |
| `scenes/main.gd` | Add gauntlet_manager init, arena setup branch, start branch |
| `scripts/managers/player_movement_manager.gd` | Add sticky cell check in `simple_move_to()`, sticky landing in push |
| `scripts/managers/goals_cycle_manager.gd` | Cleanser grant on every 2nd goal completion (gauntlet mode only) |
| `scripts/managers/special_tiles_manager.gd` | Restrict certain powerups in gauntlet mode (like Stop N Go restrictions) |
| MeshLibrary `.tres` | Add TILE_STICKY (17) and TILE_TELEGRAPH (18) mesh items |
| `scripts/managers/gauntlet_manager.gd` | **Major rewrite:** Replace cannon system with growth tick system, add candidate scoring, add candy bubble system, add movement buffer system, add layer calculation, change arena to 24×24 |
| `scripts/game_mode.gd` | Optionally rename string to `"Candy Pump Survival"` |
| `scripts/managers/lobby_manager.gd` | Optionally rename mode string; update settings (remove cannon_interval, volley_size; add growth_interval, cells_per_tick) |
| `scripts/mode_config.gd` | Update schema: remove `gauntlet_cannon_interval`, `gauntlet_volley_size`; add `gauntlet_growth_interval`, `gauntlet_cells_per_tick_phase1/2/3` |
| `scenes/main.gd` | Update mode string match if renamed |
## 6. Anti-Unfairness Implementation
---
## 6. Helper Methods Required
These utility methods need to be added to `gauntlet_manager.gd`:
```gdscript
# In CandyCannonController._select_targets():
func _select_targets(count: int) -> Array[Vector2i]:
var targets: Array[Vector2i] = []
var players = get_tree().get_nodes_in_group("Players")
# Cell validation
func _is_cell_valid_for_growth(pos: Vector2i) -> bool
func _is_cell_valid_for_bubble(pos: Vector2i) -> bool
func _is_cell_safe(pos: Vector2i) -> bool
for i in range(count):
var roll = randf()
var target: Vector2i
# Neighbor queries
func _get_8_neighbors(pos: Vector2i) -> Array[Vector2i]
func _flood_fill_safe_cluster(start: Vector2i, visited: Dictionary) -> Array[Vector2i]
if roll < 0.60:
# Near a player (not same as last targeted)
target = _get_near_player_target(players)
elif roll < 0.85:
# Route-blocking (pathfinding bottleneck)
target = _get_route_blocking_target()
elif roll < 0.95:
# Random non-sticky
target = _get_random_non_sticky_target()
else:
# Chaos (anywhere)
target = _get_random_target()
# Cluster analysis
func _expands_sticky_cluster(pos: Vector2i) -> bool
func _connects_sticky_clusters(pos: Vector2i) -> bool
func _is_near_untouched_cluster(pos: Vector2i) -> bool
func _is_critical_for_movement(cluster: Array) -> bool
targets.append(target)
# Route analysis
func _is_high_traffic_route(pos: Vector2i) -> bool
func _is_important_for_scoring(pos: Vector2i) -> bool
func _would_trap_player(pos: Vector2i) -> bool
func _removes_last_exit(pos: Vector2i) -> bool
func _makes_route_too_narrow(pos: Vector2i) -> bool
func _would_create_unfair_trap(pos: Vector2i) -> bool
func _has_reachable_safe_cell(from: Vector2i, temp_sticky: Dictionary, radius: int) -> bool
return targets
# Repetition tracking
func _was_recently_targeted(pos: Vector2i) -> bool
func _region_targeted_repeatedly(pos: Vector2i) -> bool
# Anti-unfairness rules:
# 1. last_targeted_player_id tracking prevents same-player targeting
# 2. 2×2 shots never placed directly ON a player (offset by 1)
# 3. Path validation: ensure at least one path from each active player
# to a non-sticky region (using EnhancedGridMap.initialize_astar())
# 4. Exception: final 30s allows aggressive blocking
# Bubble helpers
func _select_bubble_target(candidates: Array) -> Vector2i
func _replace_with_safer_candidates(selected: Array[Vector2i], count: int) -> Array[Vector2i]
```
## 7. Network Considerations
---
All sync follows existing patterns:
## 7. Implementation Priority (Recommended Order)
| Data | Sync Method | Existing Pattern |
|---|---|---|
| Sticky cells | `main.rpc("sync_grid_item", x, 2, z, 17)` | Safe zone / freeze overlay |
| Telegraph | `rpc("sync_telegraph", targets_array)` | `StopNGoManager.sync_phase()` |
| Phase changes | `rpc("sync_gauntlet_phase", phase_idx, elapsed)` | `StopNGoManager.sync_phase()` |
| Trap state | `player.rpc("sync_trapped", true)` | `player.rpc("sync_stop_freeze", true)` |
| Cleanser grant | `rpc("sync_cleanser", peer_id, count)` | `goals_cycle_manager.sync_goal_count()` |
| Smack state | `player.rpc("sync_smack_state", charged)` | `player.rpc("sync_modulate", color)` |
| Cannon NPC | Static scene, no movement sync needed | `static_tekton_stand.tscn` |
1. **Update arena to 24×24** — Modify `_setup_arena()`, update `NPC_CENTER`, update `_build_arena_layers()`
2. **Replace cannon with growth tick** — Remove `_fire_volley()`, add `_process_growth_tick()`, `_generate_candidates()`, `_calculate_candidate_score()`
3. **Weighted cell selection**`_select_cells_weighted()`, sticky application, A* cost update
4. **Movement buffer system**`_detect_movement_buffers()`, `_decay_movement_buffers()`, buffer penalty in scoring
5. **Path safety check**`_apply_path_safety()`, `_has_reachable_safe_cell()`, replace unsafe selections
6. **Candy bubble system** — Bubble timer, `_try_spawn_bubble()`, bubble scoring, `_explode_bubble()`
7. **Camping detection**`_update_camping_tracker()`, camping score in candidate and bubble scoring
8. **Update HUD** — Growth tick indicator, bubble warning, phase label
9. **Network sync** — New RPCs for growth telegraph, bubble spawn/explode
10. **Bot AI** — Sticky avoidance, pathfinding through sticky, cleanser usage
11. **Polish** — VFX for growth ticks, bubble animations, screen shake on explosion, sound effects
12. **Update lobby settings** — Replace cannon/volley settings with growth settings in `lobby_manager.gd` and `mode_config.gd`
## 8. Implementation Priority (Recommended Order)
---
1. **Game Mode Registration**`game_mode.gd`, `lobby_manager.gd`, `main.gd` branches
2. **Arena Setup**`gauntlet_manager._setup_arena()`, 20×20 grid, NPC zone block
3. **Tile Spawning** — Reuse `StopNGoManager._spawn_mission_tiles()` pattern
4. **Cannon Timer + Volley** — Basic 5s interval, 5 shots, 1×1 only (no sizes yet)
5. **Sticky Cell System** — Layer 2 overlay, movement blocking, trap detection
6. **Telegraph VFX** — Warning glow → impact
7. **Impact Sizes** — 1×2 and 2×2 shapes, phase-based weights
8. **Smack Mechanic** — Modified push with cooldown/charge
9. **Cleanser** — Unlock tracking, activated movement through sticky
10. **Targeting Intelligence** — Player proximity, route blocking, anti-unfairness
11. **Bot AI** — Cannon avoidance, sticky path planning
12. **Polish** — VFX, SFX, HUD animations, 3D arena scene
## 9. Risk Assessment
## 8. Risk Assessment
| Risk | Mitigation |
|---|---|
| GridMap Layer 2 conflict with existing freeze/safe overlays | Gauntlet mode is exclusive — no freeze/safe tiles in this mode |
| 20×20 grid performance (400 cells + overlays) | Existing 23×12 (Stop N Go) and 14×14 (Tekton Doors) work fine; 20×20 is comparable |
| Cannon targeting causing impossible arenas | Anti-unfairness pathfinding check via `EnhancedGridMap.initialize_astar()` |
| New MeshLibrary items (17, 18) colliding with existing IDs | Verify current max ID in `.tres` before adding |
| Smack clash detection timing | Use server-authoritative timestamp comparison (< 0.5s window) |
| 24×24 grid performance (576 cells + scoring every 3s) | Scoring runs on server only; candidate list is max 567 cells; weighted selection is O(n log n) |
| Movement buffer creating invisible safe zones that feel unfair | Buffers decay aggressively; camping override removes them; final 30s removes most; players experience it as "uneven growth" not "protected zones" |
| Path safety check preventing any arena pressure | Only triggers when a player would be fully trapped; final 30s disables strict check |
| Bubble stacking creating unavoidable traps | RecentBubblePenalty (-50) prevents nearby bubbles; max 5 per round; UnfairTrapPenalty (-100) prevents instant failures |
| Candidate scoring feeling too complex to tune | Start with simple weights; each component is independent and tunable; playtest to adjust |
| A* pathfinding cost updates every 3s causing lag | `update_astar_costs()` is lightweight (updates existing AStar2D); only runs on server |
+1209
View File
@@ -0,0 +1,1209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Candy Pump Survival — Gameplay Visualization</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
color: #eee;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
overflow-x: hidden;
}
h1 {
margin: 12px 0 4px;
font-size: 22px;
color: #ff99cc;
text-shadow: 0 0 12px #ff66b288;
letter-spacing: 1px;
}
.subtitle {
font-size: 12px;
color: #888;
margin-bottom: 8px;
}
#top-bar {
display: flex;
gap: 24px;
align-items: center;
margin-bottom: 6px;
flex-wrap: wrap;
justify-content: center;
}
.stat-box {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 6px 14px;
text-align: center;
min-width: 90px;
}
.stat-box .label { font-size: 10px; color: #888; text-transform: uppercase; }
.stat-box .value { font-size: 22px; font-weight: bold; }
.stat-box .value.timer { color: #ffcc33; }
.stat-box .value.phase1 { color: #ff99cc; }
.stat-box .value.phase2 { color: #ffcc33; }
.stat-box .value.phase3 { color: #ff4d4d; }
.stat-box .value.score { color: #61ff54; }
.stat-box .value.sticky { color: #ff66b2; }
#game-container {
position: relative;
display: flex;
gap: 12px;
align-items: flex-start;
}
canvas {
border: 2px solid #333;
border-radius: 4px;
cursor: crosshair;
}
#sidebar {
width: 200px;
display: flex;
flex-direction: column;
gap: 8px;
}
.panel {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 10px;
}
.panel h3 {
font-size: 12px;
color: #ff99cc;
text-transform: uppercase;
margin-bottom: 6px;
border-bottom: 1px solid #333;
padding-bottom: 4px;
}
.player-entry {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 13px;
}
.player-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #000;
flex-shrink: 0;
}
.player-name { flex: 1; }
.player-score { font-weight: bold; color: #61ff54; font-size: 15px; }
.legend {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #aaa;
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid #555;
}
.controls {
font-size: 11px;
color: #666;
margin-top: 4px;
line-height: 1.6;
}
.controls kbd {
background: #222;
border: 1px solid #444;
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
color: #ccc;
}
#bottom-bar {
display: flex;
gap: 16px;
margin-top: 8px;
flex-wrap: wrap;
justify-content: center;
}
.log-entry {
font-size: 11px;
color: #888;
padding: 1px 0;
}
.log-entry.trap { color: #ff4d4d; }
.log-entry.cleanse { color: #66e6ff; }
.log-entry.bubble { color: #ff99cc; }
.log-entry.score { color: #61ff54; }
.log-entry.phase { color: #ffcc33; }
</style>
</head>
<body>
<h1>Candy Pump Survival</h1>
<div class="subtitle">Gauntlet Mode — Gameplay Visualization</div>
<div id="top-bar">
<div class="stat-box"><div class="label">Timer</div><div class="value timer" id="timer">3:00</div></div>
<div class="stat-box"><div class="label">Phase</div><div class="value phase1" id="phase-label">OUTER PRESSURE</div></div>
<div class="stat-box"><div class="label">Coverage</div><div class="value sticky" id="coverage">0%</div></div>
<div class="stat-box"><div class="label">Growth Tick</div><div class="value" id="tick-count" style="color:#ff99cc">0</div></div>
<div class="stat-box"><div class="label">Bubbles</div><div class="value" id="bubble-count" style="color:#ff66cc">0</div></div>
</div>
<div id="game-container">
<canvas id="canvas"></canvas>
<div id="sidebar">
<div class="panel">
<h3>Players</h3>
<div id="player-list"></div>
</div>
<div class="panel">
<h3>Legend</h3>
<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background:#4f4f4f"></div>Safe</div>
<div class="legend-item"><div class="legend-swatch" style="background:#d6268a"></div>Sticky</div>
<div class="legend-item"><div class="legend-swatch" style="background:#e8a020"></div>Telegraph</div>
<div class="legend-item"><div class="legend-swatch" style="background:#ff5722"></div>Bubble</div>
<div class="legend-item"><div class="legend-swatch" style="background:#cc3380"></div>NPC</div>
<div class="legend-item"><div class="legend-swatch" style="background:#d10000"></div>Wall</div>
<div class="legend-item"><div class="legend-swatch" style="background:#66e6ff"></div>Cleansed</div>
<div class="legend-item"><div class="legend-swatch" style="background:#e8ba38"></div>Mission</div>
</div>
</div>
<div class="panel">
<h3>Controls</h3>
<div class="controls">
All 4 players are autonomous bots.<br>
<kbd>R</kbd> Restart simulation<br>
<kbd>P</kbd> Pause / resume<br>
<kbd>D</kbd> Toggle bot paths
</div>
</div>
</div>
</div>
<div id="bottom-bar">
<div class="panel" style="min-width:500px;max-width:700px">
<h3>Event Log</h3>
<div id="log" style="max-height:120px;overflow-y:auto;font-size:11px;"></div>
</div>
</div>
<script>
// ── Constants ──
const ARENA = 24;
const NPC_CENTER = { x: 11, y: 11 };
const NPC_SIZE = 3;
const CELL_PX = 26;
const WALL = 4, WALKABLE = 0, STICKY = 17, TELEGRAPH = 18, NPC_BLOCK = 5;
const TOTAL_PLAYABLE = ARENA * ARENA - NPC_SIZE * NPC_SIZE;
const PHASE_NAMES = ['OUTER PRESSURE', 'MIDDLE PRESSURE', 'INNER SURVIVAL'];
const PHASE_CLASSES = ['phase1', 'phase2', 'phase3'];
const PHASE_GROWTH = [
{ min: 4, max: 6, bubbles: 0 },
{ min: 6, max: 8, bubbles: 2 },
{ min: 8, max: 10, bubbles: 3 },
];
const PLAYER_COLORS = ['#33cc33', '#4d80ff', '#ff9933', '#cc66ff'];
const PLAYER_NAMES = ['Pip', 'Dabro', 'Gatot', 'Copper'];
// ── Canvas ──
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = ARENA * CELL_PX;
canvas.height = ARENA * CELL_PX;
// ── Game State ──
let grid = []; // 0=walkable, 4=wall, 5=npc, 17=sticky, 18=telegraph, 6=cleansed
let cellScores = []; // candidate scores for debug overlay
let missionTiles = {}; // "x,y" -> true if has mission tile
let players = [];
let elapsed = 0;
let paused = false;
let growthTimer = 0;
let totalTicks = 0;
let bubblesSpawned = 0;
let activeBubbles = []; // {x, y, growTimer, maxGrow}
let recentBubbles = [];
let cleanserCount = [0, 0, 0, 0];
let cleanserActive = [false, false, false, false];
let cleanserCellsLeft = [0, 0, 0, 0];
let trapped = [false, false, false, false];
let scores = [0, 0, 0, 0];
let missions = [0, 0, 0, 0];
let logs = [];
let currentPhase = 0;
let showPaths = false;
// ── Initialization ──
function initGrid() {
grid = [];
cellScores = [];
for (let y = 0; y < ARENA; y++) {
grid[y] = [];
cellScores[y] = [];
for (let x = 0; x < ARENA; x++) {
// Walls on perimeter
if (x === 0 || y === 0 || x === ARENA - 1 || y === ARENA - 1) {
grid[y][x] = WALL;
}
// NPC center 3x3
else if (x >= NPC_CENTER.x - 1 && x <= NPC_CENTER.x + 1 &&
y >= NPC_CENTER.y - 1 && y <= NPC_CENTER.y + 1) {
grid[y][x] = NPC_BLOCK;
}
else {
grid[y][x] = WALKABLE;
}
cellScores[y][x] = 0;
}
}
}
function initMissionTiles() {
missionTiles = {};
for (let y = 1; y < ARENA - 1; y++) {
for (let x = 1; x < ARENA - 1; x++) {
if (grid[y][x] === WALKABLE) {
missionTiles[`${x},${y}`] = true;
}
}
}
}
function initPlayers() {
const spawns = [
{ x: 2, y: 2 }, { x: ARENA - 3, y: 2 },
{ x: 2, y: ARENA - 3 }, { x: ARENA - 3, y: ARENA - 3 }
];
players = [];
for (let i = 0; i < 4; i++) {
players.push({
x: spawns[i].x, y: spawns[i].y,
targetX: spawns[i].x, targetY: spawns[i].y,
moving: false, moveProgress: 0,
startX: spawns[i].x, startY: spawns[i].y
});
}
}
function resetGame() {
elapsed = 0;
growthTimer = 0;
totalTicks = 0;
bubblesSpawned = 0;
activeBubbles = [];
recentBubbles = [];
currentPhase = 0;
cleanserCount = [0, 0, 0, 0];
cleanserActive = [false, false, false, false];
cleanserCellsLeft = [0, 0, 0, 0];
trapped = [false, false, false, false];
scores = [0, 0, 0, 0];
missions = [0, 0, 0, 0];
logs = [];
initGrid();
initMissionTiles();
initPlayers();
addLog('Game started! Collect tiles and survive the candy.', 'phase');
updateUI();
}
// ── Logging ──
function addLog(msg, cls = '') {
logs.unshift({ msg, cls, time: formatTime(elapsed) });
if (logs.length > 50) logs.pop();
renderLog();
}
function renderLog() {
const el = document.getElementById('log');
el.innerHTML = logs.slice(0, 15).map(l =>
`<div class="log-entry ${l.cls}">[${l.time}] ${l.msg}</div>`
).join('');
}
// ── Layer / Edge Distance ──
function getLayer(x, y) {
const edge = Math.min(x, y, ARENA - 1 - x, ARENA - 1 - y);
if (edge <= 3) return 'outer';
if (edge <= 7) return 'middle';
return 'inner';
}
function getEdgeDistance(x, y) {
return Math.min(x, y, ARENA - 1 - x, ARENA - 1 - y);
}
// ── Candidate Scoring ──
function isCellValidForGrowth(x, y) {
if (x < 0 || y < 0 || x >= ARENA || y >= ARENA) return false;
return grid[y][x] === WALKABLE || grid[y][x] === 6; // walkable or cleansed
}
function getNeighbors8(x, y) {
const n = [];
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < ARENA && ny < ARENA) n.push({ x: nx, y: ny });
}
}
return n;
}
function countStickyNeighbors(x, y) {
return getNeighbors8(x, y).filter(n => grid[n.y][n.x] === STICKY).length;
}
function expandsStickyCluster(x, y) {
const neighbors = getNeighbors8(x, y);
return neighbors.filter(n => grid[n.y][n.x] === STICKY).length >= 2;
}
function connectsStickyClusters(x, y) {
// Check if placing sticky here connects two separate sticky regions
const neighbors = getNeighbors8(x, y).filter(n => grid[n.y][n.x] === STICKY);
if (neighbors.length < 2) return false;
// Simple heuristic: multiple sticky neighbors from different directions
const dirs = neighbors.map(n => Math.atan2(n.y - y, n.x - x));
const uniqueDirs = new Set(dirs.map(d => Math.round(d * 10) / 10));
return uniqueDirs.size >= 3;
}
function distToCenter(x, y) {
return Math.sqrt((x - NPC_CENTER.x) ** 2 + (y - NPC_CENTER.y) ** 2);
}
function distToNearestPlayer(x, y) {
let minD = Infinity;
for (const p of players) {
if (!trapped[players.indexOf(p)]) {
const d = Math.sqrt((x - p.x) ** 2 + (y - p.y) ** 2);
if (d < minD) minD = d;
}
}
return minD;
}
function wouldTrapPlayer(x, y) {
// Simplified: check if any active player would have no safe neighbors
for (let i = 0; i < 4; i++) {
if (trapped[i]) continue;
const p = players[i];
const pdist = Math.sqrt((x - p.x) ** 2 + (y - p.y) ** 2);
if (pdist > 8) continue;
// Count safe neighbors of player
const safeNeighbors = getNeighbors8(Math.round(p.x), Math.round(p.y))
.filter(n => grid[n.y][n.x] === WALKABLE || grid[n.y][n.x] === 6);
if (safeNeighbors.length <= 1) return true;
}
return false;
}
function calculateCandidateScore(x, y) {
let score = 0;
const layer = getLayer(x, y);
// LayerPriority
const layerScores = [
{ outer: 60, middle: 15, inner: -40 },
{ outer: 20, middle: 60, inner: 5 },
{ outer: 10, middle: 35, inner: 60 },
];
score += layerScores[currentPhase][layer] || 0;
// StickyNeighborScore
score += countStickyNeighbors(x, y) * 8;
// InwardPressureScore
const centerDist = distToCenter(x, y);
const maxDist = distToCenter(1, 1);
const inwardRatio = 1 - centerDist / maxDist;
const inwardScores = [
[0, 10], [5, 20], [10, 30]
];
score += inwardScores[currentPhase][0] + (inwardScores[currentPhase][1] - inwardScores[currentPhase][0]) * inwardRatio;
// PlayerPressureScore
const playerDist = distToNearestPlayer(x, y);
if (playerDist >= 2 && playerDist <= 4) score += 20;
else if (playerDist < 1) score += elapsed > 150 ? 10 : -50;
// ClusterGrowthScore
if (connectsStickyClusters(x, y)) score += 25;
else if (expandsStickyCluster(x, y)) score += 15;
// RandomNoise
score += (Math.random() * 40 - 20);
// PathSafetyPenalty
if (wouldTrapPlayer(x, y) && elapsed < 150) score -= 100;
// RepetitionPenalty (simplified)
const neighbors = getNeighbors8(x, y);
const recentTargets = neighbors.filter(n => grid[n.y][n.x] === TELEGRAPH);
if (recentTargets.length > 0) score -= 30;
return score;
}
// ── Growth Tick ──
function processGrowthTick() {
const config = PHASE_GROWTH[currentPhase];
const cellCount = config.min + Math.floor(Math.random() * (config.max - config.min + 1));
// Generate candidates
const candidates = [];
for (let y = 1; y < ARENA - 1; y++) {
for (let x = 1; x < ARENA - 1; x++) {
if (!isCellValidForGrowth(x, y)) continue;
const score = calculateCandidateScore(x, y);
cellScores[y][x] = score;
candidates.push({ x, y, score: score + 100 }); // offset for positive weights
}
}
if (candidates.length === 0) return;
// Weighted random selection
candidates.sort((a, b) => b.score - a.score);
let totalWeight = candidates.reduce((s, c) => s + c.score, 0);
const selected = [];
const used = new Set();
for (let i = 0; i < Math.min(cellCount, candidates.length); i++) {
let roll = Math.random() * totalWeight;
let cumulative = 0;
for (const c of candidates) {
if (used.has(`${c.x},${c.y}`)) continue;
cumulative += c.score;
if (roll <= cumulative) {
selected.push(c);
used.add(`${c.x},${c.y}`);
totalWeight -= c.score;
break;
}
}
}
// Telegraph phase
for (const c of selected) {
grid[c.y][c.x] = TELEGRAPH;
}
totalTicks++;
// After telegraph delay, apply sticky
setTimeout(() => {
for (const c of selected) {
if (grid[c.y][c.x] === TELEGRAPH) {
grid[c.y][c.x] = STICKY;
// Check players on this cell
for (let i = 0; i < 4; i++) {
if (!trapped[i] && Math.round(players[i].x) === c.x && Math.round(players[i].y) === c.y) {
if (cleanserActive[i]) {
grid[c.y][c.x] = 6; // cleansed
cleanserCellsLeft[i]--;
if (cleanserCellsLeft[i] <= 0) {
cleanserActive[i] = false;
addLog(`${PLAYER_NAMES[i]}'s Cleanser wore off.`, 'cleanse');
}
addLog(`${PLAYER_NAMES[i]} cleansed a sticky cell!`, 'cleanse');
} else {
trapPlayer(i);
}
}
}
}
}
// Clear telegraph visuals after impact
setTimeout(() => {
for (const c of selected) {
if (grid[c.y][c.x] === TELEGRAPH) grid[c.y][c.x] = STICKY;
}
// Invalidate bot paths — grid changed
botPaths = [[], [], [], []];
}, 200);
}, 800);
}
// ── Candy Bubble System ──
function trySpawnBubble() {
const config = PHASE_GROWTH[currentPhase];
if (bubblesSpawned >= config.bubbles) return;
if (Math.random() > 0.3) return; // chance per tick
// Find valid bubble candidates
const candidates = [];
for (let y = 3; y < ARENA - 3; y++) {
for (let x = 3; x < ARENA - 3; x++) {
if (!isCellValidForGrowth(x, y)) continue;
// Check 3x3 area is mostly clear
let clear = true;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!isCellValidForGrowth(x + dx, y + dy)) { clear = false; break; }
}
if (!clear) break;
}
if (!clear) continue;
// Camping bonus
let score = 30;
for (let i = 0; i < 4; i++) {
const pdist = Math.sqrt((x - players[i].x) ** 2 + (y - players[i].y) ** 2);
if (pdist < 1.5) score -= 60; // don't spawn on player
else if (pdist <= 4) score += 20;
}
// Recent bubble penalty
for (const rb of recentBubbles) {
if (Math.sqrt((x - rb.x) ** 2 + (y - rb.y) ** 2) < 5) {
score -= 50;
break;
}
}
// Untouched area bonus
const clusterSize = floodFillCount(x, y, new Set());
if (clusterSize > 10) score += 30;
score += Math.random() * 40 - 20;
candidates.push({ x, y, score });
}
}
if (candidates.length === 0) return;
candidates.sort((a, b) => b.score - a.score);
const target = candidates[0];
activeBubbles.push({
x: target.x, y: target.y,
growTimer: 2.5 + Math.random() * 0.5,
maxGrow: 2.5 + Math.random() * 0.5
});
bubblesSpawned++;
recentBubbles.push({ x: target.x, y: target.y });
if (recentBubbles.length > 8) recentBubbles.shift();
addLog(`Candy bubble appeared near (${target.x},${target.y})!`, 'bubble');
}
function floodFillCount(x, y, visited) {
const key = `${x},${y}`;
if (visited.has(key)) return 0;
if (!isCellValidForGrowth(x, y)) return 0;
visited.add(key);
let count = 1;
for (const n of getNeighbors8(x, y)) {
count += floodFillCount(n.x, n.y, visited);
}
return count;
}
function processBubbles(dt) {
for (let i = activeBubbles.length - 1; i >= 0; i--) {
const b = activeBubbles[i];
b.growTimer -= dt;
if (b.growTimer <= 0) {
// Explode into 3x3
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = b.x + dx, ny = b.y + dy;
if (isCellValidForGrowth(nx, ny)) {
grid[ny][nx] = STICKY;
// Check players
for (let j = 0; j < 4; j++) {
if (!trapped[j] && Math.round(players[j].x) === nx && Math.round(players[j].y) === ny) {
if (cleanserActive[j]) {
grid[ny][nx] = 6;
cleanserCellsLeft[j]--;
if (cleanserCellsLeft[j] <= 0) {
cleanserActive[j] = false;
addLog(`${PLAYER_NAMES[j]}'s Cleanser wore off.`, 'cleanse');
}
} else {
trapPlayer(j);
}
}
}
}
}
}
addLog(`Bubble exploded at (${b.x},${b.y})!`, 'trap');
activeBubbles.splice(i, 1);
botPaths = [[], [], [], []];
}
}
}
// ── Player Actions ──
function trapPlayer(idx) {
// Auto-use cleanser if available
if (cleanserCount[idx] > 0 && !cleanserActive[idx]) {
cleanserCount[idx]--;
cleanserActive[idx] = true;
cleanserCellsLeft[idx] = 5;
trapped[idx] = false;
addLog(`${PLAYER_NAMES[idx]} auto-activated Cleanser to escape!`, 'cleanse');
return;
}
trapped[idx] = true;
botPaths[idx] = [];
addLog(`${PLAYER_NAMES[idx]} is TRAPPED in sticky candy!`, 'trap');
scores[idx] = Math.max(0, scores[idx] - 50);
}
// ── Movement ──
function canMoveTo(idx, x, y) {
if (x < 1 || y < 1 || x >= ARENA - 1 || y >= ARENA - 1) return false;
const cell = grid[y][x];
if (cell === WALL || cell === NPC_BLOCK) return false;
if (cell === STICKY && !cleanserActive[idx]) return false;
return true;
}
function movePlayerStep(idx, dx, dy) {
if (trapped[idx]) return;
const p = players[idx];
if (p.moving) return; // already moving
const nx = Math.round(p.x) + dx;
const ny = Math.round(p.y) + dy;
if (!canMoveTo(idx, nx, ny)) return;
p.startX = Math.round(p.x);
p.startY = Math.round(p.y);
p.targetX = nx;
p.targetY = ny;
p.moveProgress = 0;
p.moving = true;
p.moveDir = { dx, dy };
}
function updatePlayerMovement(dt) {
const speed = 10; // cells per second — fast and responsive
for (let i = 0; i < 4; i++) {
const p = players[i];
if (!p.moving) continue;
p.moveProgress += dt * speed;
if (p.moveProgress >= 1) {
p.x = p.targetX;
p.y = p.targetY;
p.moving = false;
p.moveProgress = 0;
// Collect mission tile
const key = `${Math.round(p.x)},${Math.round(p.y)}`;
if (missionTiles[key]) {
delete missionTiles[key];
missions[i]++;
scores[i] += 1000;
addLog(`${PLAYER_NAMES[i]} collected a mission tile! (${missions[i]} total)`, 'score');
if (missions[i] % 2 === 0 && cleanserCount[i] < 1) {
cleanserCount[i]++;
addLog(`${PLAYER_NAMES[i]} earned a Cleanser!`, 'cleanse');
}
}
// Check if landed on sticky
const gx = Math.round(p.x), gy = Math.round(p.y);
if (grid[gy][gx] === STICKY) {
if (cleanserActive[i]) {
grid[gy][gx] = 6; // cleansed
cleanserCellsLeft[i]--;
if (cleanserCellsLeft[i] <= 0) {
cleanserActive[i] = false;
addLog(`${PLAYER_NAMES[i]}'s Cleanser wore off.`, 'cleanse');
} else {
addLog(`${PLAYER_NAMES[i]} cleansed a cell (${cleanserCellsLeft[i]} left)`, 'cleanse');
}
} else {
trapPlayer(i);
}
}
} else {
p.x = p.startX + (p.targetX - p.startX) * p.moveProgress;
p.y = p.startY + (p.targetY - p.startY) * p.moveProgress;
}
}
}
// ── Phase Management ──
function updatePhase() {
let newPhase = 0;
if (elapsed >= 120) newPhase = 2;
else if (elapsed >= 60) newPhase = 1;
if (newPhase !== currentPhase) {
currentPhase = newPhase;
addLog(`Phase changed: ${PHASE_NAMES[currentPhase]}`, 'phase');
}
}
// ── Cooldowns ──
function updateCooldowns(_dt) {
}
// ── Bot AI ──
let botTimers = [0, 0, 0, 0];
let botPaths = [[], [], [], []];
function isWalkable(x, y) {
if (x < 0 || y < 0 || x >= ARENA || y >= ARENA) return false;
const c = grid[y][x];
return c === WALKABLE || c === 6;
}
function astar(sx, sy, tx, ty) {
if (sx === tx && sy === ty) return [];
const k = (x, y) => `${x},${y}`;
const open = new Map();
const closed = new Set();
const h = (x, y) => Math.abs(x - tx) + Math.abs(y - ty);
open.set(k(sx, sy), { x: sx, y: sy, g: 0, f: h(sx, sy), p: null });
while (open.size > 0) {
let best = null;
for (const n of open.values()) {
if (!best || n.f < best.f || (n.f === best.f && n.g > best.g)) best = n;
}
if (best.x === tx && best.y === ty) {
const path = [];
let c = best;
while (c.p) { path.unshift({ x: c.x, y: c.y }); c = c.p; }
return path;
}
open.delete(k(best.x, best.y));
closed.add(k(best.x, best.y));
for (const [dx, dy] of [[0,-1],[0,1],[-1,0],[1,0]]) {
const nx = best.x + dx, ny = best.y + dy;
const nk = k(nx, ny);
if (closed.has(nk)) continue;
if (!(nx === tx && ny === ty) && !isWalkable(nx, ny)) continue;
const g = best.g + 1;
const ex = open.get(nk);
if (ex && g >= ex.g) continue;
open.set(nk, { x: nx, y: ny, g, f: g + h(nx, ny), p: best });
}
}
return [];
}
function findNearestMission(idx) {
const p = players[idx];
const cx = Math.round(p.x), cy = Math.round(p.y);
let best = null, bestDist = Infinity;
for (const key in missionTiles) {
const [mx, my] = key.split(',').map(Number);
const d = Math.abs(mx - cx) + Math.abs(my - cy);
if (d < bestDist) { bestDist = d; best = { x: mx, y: my }; }
}
return best;
}
function botStep(idx) {
if (trapped[idx]) return;
const p = players[idx];
if (p.moving) return;
const path = botPaths[idx];
if (path.length > 0) {
const next = path[0];
if (isWalkable(next.x, next.y)) {
movePlayerStep(idx, next.x - Math.round(p.x), next.y - Math.round(p.y));
if (!p.moving) {
path.shift();
// Stuck: couldn't move to next path node, invalidate
if (path.length > 0) botPaths[idx] = [];
}
} else {
path.shift();
botPaths[idx] = [];
}
return;
}
// Try A* to nearest mission
const target = findNearestMission(idx);
if (target) {
botPaths[idx] = astar(Math.round(p.x), Math.round(p.y), target.x, target.y);
if (botPaths[idx].length > 0) {
const next = botPaths[idx].shift();
movePlayerStep(idx, next.x - Math.round(p.x), next.y - Math.round(p.y));
return;
}
}
// FALLBACK: no path to any mission — move away from nearest sticky (survival)
escapeSticky(idx);
}
function escapeSticky(idx) {
const p = players[idx];
const cx = Math.round(p.x), cy = Math.round(p.y);
// Find nearest sticky cell
let nearestSticky = null, nearDist = Infinity;
for (let y = 0; y < ARENA; y++) {
for (let x = 0; x < ARENA; x++) {
if (grid[y][x] === STICKY) {
const d = Math.abs(x - cx) + Math.abs(y - cy);
if (d < nearDist) { nearDist = d; nearestSticky = { x, y }; }
}
}
}
if (!nearestSticky) return;
// Move away from sticky: try the direction opposite to it
const dx = Math.sign(cx - nearestSticky.x);
const dy = Math.sign(cy - nearestSticky.y);
const dirs = [[dx, 0], [0, dy], [-dx, 0], [0, -dy], [1, 0], [-1, 0], [0, 1], [0, -1]];
for (const [mx, my] of dirs) {
if (mx === 0 && my === 0) continue;
if (canMoveTo(idx, cx + mx, cy + my)) {
movePlayerStep(idx, mx, my);
return;
}
}
}
function updateBots(dt) {
for (let i = 0; i < 4; i++) {
botTimers[i] -= dt;
if (botTimers[i] <= 0) {
botStep(i);
botTimers[i] = 0.12;
}
}
}
// ── Scoring ──
function updateScoring() {
// Survival bonus at end
}
// ── Rendering ──
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < ARENA; y++) {
for (let x = 0; x < ARENA; x++) {
const px = x * CELL_PX;
const py = y * CELL_PX;
const cell = grid[y][x];
// Base cell color
let color;
switch (cell) {
case WALKABLE: color = '#4f4f4f'; break;
case WALL: color = '#d10000'; break;
case NPC_BLOCK: color = '#cc3380'; break;
case STICKY: color = '#d6268a'; break; // deep magenta — set candy
case TELEGRAPH: color = '#e8a020'; break; // amber/syrup — warning glow
case 6: color = '#66e6ff'; break; // cleansed
default: color = '#4f4f4f';
}
// Layer highlight based on current pressure
if (cell === WALKABLE || cell === 6) {
const layer = getLayer(x, y);
const targetLayer = currentPhase === 0 ? 'outer' : currentPhase === 1 ? 'middle' : 'inner';
if (layer === targetLayer) {
// Subtle highlight for current pressure layer
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
color = `rgb(${Math.min(255, r + 12)},${Math.min(255, g + 8)},${Math.min(255, b + 5)})`;
}
}
ctx.fillStyle = color;
ctx.fillRect(px, py, CELL_PX - 1, CELL_PX - 1);
// Draw grid lines
ctx.strokeStyle = '#222';
ctx.lineWidth = 0.5;
ctx.strokeRect(px, py, CELL_PX, CELL_PX);
// Mission tiles (fully fill cell)
if (missionTiles[`${x},${y}`] && (cell === WALKABLE || cell === 6)) {
ctx.fillStyle = '#e8ba38';
ctx.fillRect(px + 1, py + 1, CELL_PX - 2, CELL_PX - 2);
ctx.strokeStyle = '#b8901a';
ctx.lineWidth = 1;
ctx.strokeRect(px + 1, py + 1, CELL_PX - 2, CELL_PX - 2);
// Shine highlight
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.fillRect(px + 3, py + 3, (CELL_PX - 6) * 0.4, (CELL_PX - 6) * 0.35);
}
// Telegraph pulsing effect (amber/syrup glow)
if (cell === TELEGRAPH) {
const pulse = 0.35 + Math.sin(Date.now() / 120) * 0.3;
ctx.fillStyle = `rgba(232,160,32,${pulse})`;
ctx.fillRect(px, py, CELL_PX - 1, CELL_PX - 1);
}
}
}
// Draw layer borders (subtle)
for (let y = 1; y < ARENA - 1; y++) {
for (let x = 1; x < ARENA - 1; x++) {
if (getEdgeDistance(x, y) === 4 || getEdgeDistance(x, y) === 8) {
const px = x * CELL_PX;
const py = y * CELL_PX;
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(px, py + CELL_PX);
ctx.stroke();
}
}
}
// Draw active bubbles (hot orange-red)
for (const b of activeBubbles) {
const px = b.x * CELL_PX;
const py = b.y * CELL_PX;
const progress = 1 - b.growTimer / b.maxGrow;
const size = CELL_PX * (0.3 + progress * 0.7);
// Warning 3x3 area
if (progress > 0.5) {
ctx.fillStyle = `rgba(255,87,34,${(progress - 0.5) * 0.25})`;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
ctx.fillRect((b.x + dx) * CELL_PX, (b.y + dy) * CELL_PX, CELL_PX - 1, CELL_PX - 1);
}
}
}
// Bubble itself
ctx.fillStyle = `rgba(255,87,34,${0.6 + progress * 0.4})`;
ctx.beginPath();
ctx.arc(px + CELL_PX / 2, py + CELL_PX / 2, size / 2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ff8a50';
ctx.lineWidth = 2;
ctx.stroke();
// Pulse ring
if (progress > 0.7) {
const pulseSize = size * (1 + Math.sin(Date.now() / 80) * 0.2);
ctx.strokeStyle = `rgba(255,138,80,${0.5})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(px + CELL_PX / 2, py + CELL_PX / 2, pulseSize / 2 + 4, 0, Math.PI * 2);
ctx.stroke();
}
}
// Draw players
for (let i = 0; i < 4; i++) {
const p = players[i];
const px = p.x * CELL_PX + CELL_PX / 2;
const py = p.y * CELL_PX + CELL_PX / 2;
const radius = CELL_PX / 2.5;
// Player body
ctx.fillStyle = trapped[i] ? '#666' : PLAYER_COLORS[i];
ctx.beginPath();
ctx.arc(px, py, radius, 0, Math.PI * 2);
ctx.fill();
// Trapped overlay
if (trapped[i]) {
ctx.strokeStyle = '#ff4d4d';
ctx.lineWidth = 2;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.arc(px, py, radius + 2, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// Trapped X
ctx.strokeStyle = '#ff4d4d';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(px - 4, py - 4);
ctx.lineTo(px + 4, py + 4);
ctx.moveTo(px + 4, py - 4);
ctx.lineTo(px - 4, py + 4);
ctx.stroke();
}
// Cleanser active indicator
if (cleanserActive[i]) {
ctx.strokeStyle = '#66e6ff';
ctx.lineWidth = 2;
ctx.setLineDash([4, 2]);
ctx.beginPath();
ctx.arc(px, py, radius + 8, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
// Player name
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(PLAYER_NAMES[i], px, py - radius - 6);
// Score
ctx.fillStyle = '#61ff54';
ctx.font = '8px sans-serif';
ctx.fillText(Math.round(scores[i]), px, py + radius + 10);
}
// Draw bot paths (debug toggle with D)
if (showPaths) {
const pathColors = ['#33cc3355', '#4d80ff55', '#ff993355', '#cc66ff55'];
for (let i = 0; i < 4; i++) {
const path = botPaths[i];
if (path.length === 0) continue;
ctx.strokeStyle = pathColors[i];
ctx.lineWidth = 3;
ctx.setLineDash([4, 4]);
ctx.beginPath();
const sx = players[i].x * CELL_PX + CELL_PX / 2;
const sy = players[i].y * CELL_PX + CELL_PX / 2;
ctx.moveTo(sx, sy);
for (const node of path) {
ctx.lineTo(node.x * CELL_PX + CELL_PX / 2, node.y * CELL_PX + CELL_PX / 2);
}
ctx.stroke();
ctx.setLineDash([]);
}
}
// Draw NPC center marker
const npcPx = NPC_CENTER.x * CELL_PX + CELL_PX / 2;
const npcPy = NPC_CENTER.y * CELL_PX + CELL_PX / 2;
ctx.fillStyle = '#cc3380';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('PUMP', npcPx, npcPy - 6);
ctx.font = '10px sans-serif';
ctx.fillText('NPC', npcPx, npcPy + 8);
}
// ── UI Updates ──
function formatTime(t) {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function updateUI() {
document.getElementById('timer').textContent = formatTime(180 - elapsed);
const phaseEl = document.getElementById('phase-label');
phaseEl.textContent = PHASE_NAMES[currentPhase];
phaseEl.className = 'value ' + PHASE_CLASSES[currentPhase];
const stickyCount = grid.flat().filter(c => c === STICKY).length;
const coverage = Math.round((stickyCount / TOTAL_PLAYABLE) * 100);
document.getElementById('coverage').textContent = coverage + '%';
document.getElementById('tick-count').textContent = totalTicks;
document.getElementById('bubble-count').textContent = bubblesSpawned;
// Player list
const listEl = document.getElementById('player-list');
listEl.innerHTML = players.map((p, i) => `
<div class="player-entry" style="opacity:${trapped[i] ? 0.5 : 1}">
<div class="player-dot" style="background:${PLAYER_COLORS[i]}"></div>
<span class="player-name">${PLAYER_NAMES[i]}</span>
<span class="player-score">${Math.round(scores[i])}</span>
<span class="player-missions">×${missions[i]}</span>
</div>
`).join('');
}
// ── Input ──
document.addEventListener('keydown', (e) => {
switch (e.key) {
case 'r': case 'R': resetGame(); break;
case 'p': case 'P': paused = !paused; break;
case 'd': case 'D': showPaths = !showPaths; break;
}
});
// ── Game Loop ──
let lastTime = 0;
function gameLoop(timestamp) {
const dt = Math.min((timestamp - lastTime) / 1000, 0.1);
lastTime = timestamp;
if (!paused && elapsed < 180) {
elapsed += dt;
// Growth tick
growthTimer += dt;
if (growthTimer >= 3.0) {
growthTimer -= 3.0;
processGrowthTick();
trySpawnBubble();
}
// Bubbles
processBubbles(dt);
// Player movement
updatePlayerMovement(dt);
// Phase
updatePhase();
// Cooldowns
updateCooldowns(dt);
// Bot AI — all non-selected players auto-pathfind
updateBots(dt);
// Score decay for trapped players
for (let i = 0; i < 4; i++) {
if (trapped[i]) {
scores[i] = Math.max(0, scores[i] - dt * 2);
}
}
// Passive score for surviving
for (let i = 0; i < 4; i++) {
if (!trapped[i]) {
scores[i] += dt * 1;
}
}
}
draw();
if (Math.floor(elapsed * 4) % 2 === 0) updateUI(); // throttle UI updates
requestAnimationFrame(gameLoop);
}
// ── Start ──
resetGame();
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
+841 -121
View File
@@ -1,24 +1,52 @@
# Candy Cannon Survival (Gauntlet) — Game Design Document
# Candy Pump Survival (Gauntlet) — Game Design Document
## 1. High Concept
A time-limited survival-collection arena mode where players move across a grid of colored tiles, collect mission-specific tiles for points, and survive a central NPC that fires candy cannon shots across the arena. Candy impacts turn cells sticky and unpassable. Players must keep scoring while the arena slowly becomes dangerous. The stage pressure escalates over 3 minutes. By the end of the round, around 80% of the playable arena is sticky candy, leaving only 20% safe space.
Candy Pump Survival is a time-limited survival-collection arena mode where players move across a grid of colored tiles, collect mission-specific tiles for points, and survive as candy pressure spreads under the arena.
A Candy Pump NPC injects candy into the ground. The candy slowly pops up through the floor, turning cells into sticky candy. Sticky cells are dangerous, unpassable, and can trap players.
The candy growth is not fully random. It generally spreads from the outer arena inward. This creates a shrinking-arena feeling where players are slowly pushed toward the center, but the system should not create a perfectly readable circle or square. The candy should grow in broken, organic, semi-predictable patterns.
Players should be able to understand the general danger flow:
"The outer arena is becoming dangerous first."
But players should not be able to perfectly predict every cell that will become sticky.
The stage pressure escalates over 3 minutes. The round starts open and playful, then becomes tighter, more tactical, and more desperate as the candy spreads inward.
## 2. Core Fantasy
Players feel like they are racing through a colorful board-game arena while the floor is being eaten by candy chaos. The round starts open and playful, then becomes tighter, more tactical, and more desperate as safe routes disappear.
Players feel like they are racing through a colorful board-game arena while the floor itself is being infected by candy.
The candy is not just an attack. It is a spreading environmental pressure.
Players need to:
- collect mission tiles,
- read the candy flow,
- avoid sticky cells,
- escape with Cleanser,
- sabotage opponents,
- avoid candy bubbles,
- and survive the arena closing inward.
The ideal emotional curve:
- First minute: playful collection and early outer-rim pressure.
- Second minute: route planning, middle-ring pressure, and bubble disruption.
- Final minute: chaotic survival, Cleanser use, sabotage, and clutch escapes.
## 3. Game Mode Summary
| Property | Value |
|---|---|
| Mode Name | Candy Cannon Survival |
| Mode Name | Candy Pump Survival |
| Round Duration | 3 minutes |
| Recommended Players | 48 |
| Arena Type | Cell/grid-based arena |
| Primary Goal | Score points by collecting mission-required colored tiles |
| Secondary Goal | Survive until the timer ends |
| Main Hazard | Central NPC candy cannon creates sticky unpassable cells |
| Main Hazard | Candy Pump ground infection that creates sticky unpassable cells |
| Special Hazard | Candy bubbles that grow from 1×1 and explode into 3×3 sticky areas |
| Player Interaction | Smack/sabotage other players into danger |
| Comeback Tool | Cleanser power-up after completing 2 missions |
@@ -26,111 +54,699 @@ Players feel like they are racing through a colorful board-game arena while the
| Metric | Value |
|---|---|
| Arena Size | 20×20 cells |
| Total Cells | 400 |
| Central NPC footprint | 3×3 = 9 cells |
| Playable Cells | 391 |
| Target sticky coverage (3 min) | 80% = 313 sticky cells |
| Remaining safe cells | 78 |
| Arena Size | 24×24 cells |
| Total Cells | 576 |
| Candy Pump NPC footprint | 3×3 = 9 blocked cells |
| Playable Cells | 567 |
| Target sticky coverage (3 min) | 70%75% = 397425 sticky cells |
| Remaining safe cells | 142170 |
## 5. Arena Layout
### Why 24×24
- Grid: 20×20 square grid
- Each cell contains a colored tile
- Center 3×3 area occupied by Candy Cannon NPC (permanently blocked)
- Player spawns near outer edges / corners
- Mission tiles distributed across arena
The 24×24 arena is recommended because this mode uses several overlapping systems:
- inward sticky growth,
- cellular-automation-style candy spread,
- hidden movement buffers,
- Cleanser route carving,
- player sabotage,
- occasional candy bubbles,
- and 48 player movement.
**Spawn positions:**
- 4 players: top-left, top-right, bottom-left, bottom-right outer quadrants
- 68 players: side-edge and corner-adjacent spawns
A 24×24 arena gives enough room for:
- readable movement,
- organic candy growth,
- temporary untouched spaces,
- risky escape routes,
- bubble disruption,
- player fighting,
- and final-minute survival tension.
## 6. Candy Cannon NPC
## 5. Arena Layer Logic
- Position: Exact center, 3×3 cell area
- Fires candy cannon shots at cells
- Impacted cells become **sticky**
The arena is divided into three pressure layers based on distance from the outer edge. The sticky growth system uses these layers to push players inward over time.
### Sticky Cell Rules
### Edge Distance Formula
- Cannot be passed through
- Cannot be collected from
- Traps players who step onto it
- Traps players pushed into it
- Remains sticky until cleansed or round ends
For each cell:
### Player Hit Rule
```
edgeDistance = min(x, y, width - 1 - x, height - 1 - y)
```
- Direct hit → player is **trapped** (not eliminated)
- If they have Cleanser, they may escape
- Without Cleanser, they are out of active play
Where:
- x = cell horizontal coordinate
- y = cell vertical coordinate
- width = arena width (24)
- height = arena height (24)
## 7. Candy Cannon Timing & Math
### Layer Definitions
- Cannon fires **one volley every 5 seconds**
- Each volley: **5 shots** at different target cells
- 180 seconds / 5 = **36 volleys**
- 36 × 5 = **180 total impacts**
| Layer | edgeDistance | Approximate Size |
|---|---|---|
| Outer Layer | 03 | 320 cells |
| Middle Layer | 47 | 192 cells |
| Inner Layer | 8+ | 64 cells (55 playable after 3×3 NPC) |
### Telegraph
- **1 second** telegraph before impact (pink glow, syrup preview, charge sound, final flash)
### Impact Size Mix (full round)
| Size | Chance |
| Layer | Purpose |
|---|---|
| 1×1 (1 cell) | 35% |
| 1×2 (2 cells) | 50% |
| 2×2 (4 cells) | 15% |
| Outer Layer | First area to become unsafe. Teaches players that the arena is closing inward. |
| Middle Layer | Main route-pressure area. Players must move through shrinking paths, fight over space, and avoid being pushed into sticky candy. |
| Inner Layer | Final survival area. Pressured in the final minute, but should not become completely safe or completely sealed too early. |
### Phase-Based Volley Pattern
### Important Layer Rule
| Time | Shots | Mix | Purpose |
|---|---|---|---|
| 0:001:00 | 5/volley | Mostly 1×1, 1×2 | Slow arena pressure |
| 1:002:00 | 5/volley | Mostly 1×2, some 2×2 | Route cutting |
| 2:003:00 | 5/volley | More 2×2 | Strong endgame pressure |
The layer system should guide candy growth, not create perfect ring filling.
| Phase | 1×1 | 1×2 | 2×2 |
|---|---|---|---|
| 0:001:00 | 60% | 40% | 0% |
| 1:002:00 | 30% | 55% | 15% |
| 2:003:00 | 15% | 55% | 30% |
**Good behavior:**
- Candy mostly grows in the current active layer.
- Some cells in nearby layers can be selected.
- Growth appears as broken patches.
- Some spaces are left untouched temporarily.
- The candy flow feels readable but imperfect.
## 8. Cannon Targeting Logic — Controlled Chaos
**Bad behavior:**
- The full outer ring becomes sticky at once.
- The arena fills as a perfect square.
- Players can simply run to the center and wait.
- Every cell follows a predictable pattern.
| Chance | Target |
## 6. Arena Layout
- Grid: 24×24 square grid
- Each cell contains a colored tile unless blocked
- Candy Pump NPC occupies the center 3×3 area (permanently blocked)
- Player spawn points placed near outer edges or corners
- Mission tiles distributed across the arena
### Recommended Spawn Positions
**4 players:**
- Top-left outer quadrant
- Top-right outer quadrant
- Bottom-left outer quadrant
- Bottom-right outer quadrant
**68 players:**
- Use side-edge spawns and corner-adjacent spawns
- Avoid placing players directly beside the center
- Avoid placing all players on the same route
- Avoid placing players where the first candy growth immediately cuts them off
## 7. Cell States
Each arena cell should have one current state:
| State | Description |
|---|---|
| 60% | Non-sticky area near a player |
| 25% | Non-sticky area blocking common routes |
| 10% | Random non-sticky area |
| 5% | Previously sticky / semi-blocked (chaos) |
| SAFE | Cell can be entered, crossed, and collected. |
| TELEGRAPHED | Cell is warned as a future sticky cell. Still passable during the telegraph. |
| STICKY | Cell is covered in sticky candy. Cannot be passed through or collected from. A player entering this cell becomes trapped. |
| BUBBLE_GROWING | A candy bubble is growing on this cell. Visible and dangerous, but has not exploded yet. |
| BUBBLE_WARNING_AREA | The 3×3 area around a growing bubble that will become sticky when the bubble explodes. |
| BLOCKED | Cell is occupied by the Candy Pump NPC or another permanent obstacle. |
| CLEANSED | Optional temporary state for a cell recently cleaned by Cleanser. May simply return to SAFE, or may receive short protection from immediate re-growth. |
### Anti-Unfairness Rules
## 8. Candy Pump NPC
- Do not target same player twice in a row
- Do not place 2×2 directly on a player without warning
- Do not fully seal all exits from a pocket (except final 30s)
- Maintain at least one path from each active player to a safe region when possible
### NPC Position
## 9. Telegraph System
The Candy Pump NPC sits in the center of the arena and occupies a 3×3 cell area.
- **1 second** warning before impact
- Target cell glows pink/candy color
- Shadow or syrup splash preview
- Impact sound charges up
- Final 0.2s flash before landing
### NPC Function
## 10. Sticky Trap Rules
The Candy Pump NPC pumps candy into the ground. The candy pressure spreads beneath the arena and causes sticky candy to pop out of floor cells.
- Step onto sticky cell → **trapped**
- Cannot move normally
- Escape only via Cleanser
- Direct cannon hit → trapped (Cleanser may save)
- Pushed into sticky → trapped (pusher rewarded indirectly)
The NPC is the source of the arena hazard, but it does not directly shoot individual targets.
## 11. Cleanser Power-Up
### Design Role
The Candy Pump NPC creates:
- slow outside-to-inside arena pressure,
- sticky cell growth,
- irregular candy spread,
- hidden movement buffer behavior,
- candy bubble events,
- and final-minute survival tension.
## 9. Sticky Cell Rules
A sticky cell:
- Cannot be passed through.
- Cannot be collected from.
- Traps players who step onto it.
- Traps players who are pushed into it.
- Remains sticky until cleansed or until the round ends.
### Entering Sticky Cells
If a player steps onto a sticky cell:
- They become trapped.
- They cannot move normally.
- They can only escape using Cleanser.
### Pushed Into Sticky Cell
If a player is smacked and pushed into sticky candy:
- They become trapped.
- The pushing player is rewarded indirectly by removing or delaying a competitor.
## 10. Core Sticky Growth System
The main hazard is normal sticky ground growth. Sticky growth happens in repeated ticks. Each growth tick selects several SAFE cells, telegraphs them, then converts them into STICKY cells after a short delay.
The sticky growth uses a cellular-automation-style system. This means candy should prefer to grow near existing sticky candy, but still use enough randomness to avoid perfect patterns.
The growth should feel:
- organic,
- broken,
- semi-predictable,
- and shaped by the phase of the match.
Players should understand:
- "The candy is generally moving inward."
Players should not know:
- "Exactly which cells will become sticky next."
## 11. Sticky Growth Timing
| Property | Value |
|---|---|
| Total round duration | 180 seconds |
| Normal sticky growth interval | Every 3 seconds |
| Normal telegraph duration | 1 second |
| Total growth ticks | 180 ÷ 3 = 60 ticks |
| Ticks per phase | 20 ticks each |
### Recommended Sticky Cells Per Tick
| Phase | Cells per Tick |
|---|---|
| Phase 1 | 46 cells |
| Phase 2 | 68 cells |
| Phase 3 | 810 cells |
### Coverage Estimate
| Phase | Calculation | Sticky Attempts |
|---|---|---|
| Phase 1 | 20 ticks × 46 cells | 80120 |
| Phase 2 | 20 ticks × 68 cells | 120160 |
| Phase 3 | 20 ticks × 810 cells | 160200 |
| **Total** | | **360480** |
These are sticky attempts, not guaranteed unique sticky cells. Some attempts may:
- overlap existing sticky cells,
- be rejected by the movement buffer system,
- be rejected by path-safety logic,
- hit blocked areas,
- be cleansed by players,
- or be replaced by another candidate.
## 12. Candy Bubble System
Candy bubbles are occasional anti-camping hazards. They are not the main sticky growth system. A candy bubble starts as a 1×1 growing bubble. It grows for several seconds, then explodes into a 3×3 sticky area.
### Bubble Purpose
Candy bubbles exist to:
- break camping behavior,
- force players out of comfortable positions,
- disperse grouped players,
- create panic moments,
- pressure players who refuse to move,
- and punish overuse of untouched areas.
Candy bubbles should not be used as the main source of arena coverage.
### Bubble Timing
| Phase | Bubbles |
|---|---|
| Phase 1 | 0 |
| Phase 2 | 2 |
| Phase 3 | 3 |
| **Total** | **5** |
| Property | Value |
|---|---|
| Bubble Grow Duration | 2.53 seconds |
| Bubble Explosion Size | 3×3 area |
### Bubble Coverage Math
Each bubble explosion affects up to 9 cells.
| Phase | Calculation | Sticky Attempts |
|---|---|---|
| Phase 2 | 2 bubbles × 9 cells | 18 |
| Phase 3 | 3 bubbles × 9 cells | 27 |
| **Total** | | **45** |
Because bubbles may overlap sticky cells, blocked cells, or previously unsafe areas, the actual unique sticky gain may be lower than 45 cells.
### Bubble Spawn Rules
A bubble may spawn on a SAFE cell.
**Recommended bubble target priorities:**
- near players who stay in the same area too long,
- inside or near large untouched spaces,
- near important mission routes,
- near contested areas,
- near groups of players,
- not directly on top of a player unless strongly telegraphed and avoidable.
### Bubble Anti-Unfairness Rules
- Do not spawn a bubble with no readable warning.
- Do not spawn a bubble that instantly traps all nearby players.
- Do not stack multiple bubbles in the same small area.
- Do not spawn a bubble repeatedly in the exact same region.
- Do not use bubbles as the main map-filling tool.
## 13. Phase Structure
### Phase 1 — Outer Pressure (0:001:00)
**Purpose:**
- Introduce candy ground growth.
- Teach players that the outer arena is becoming dangerous.
- Keep the early game playable and understandable.
- Let players collect tiles and explore routes.
**Normal Sticky Growth:**
- Growth tick every 3 seconds.
- 46 cells selected per tick.
- 1 second telegraph before cells become sticky.
- Growth mainly affects the Outer Layer.
**Recommended growth distribution:**
- 75% Outer Layer
- 10% Middle Layer early warning
- 10% near-player pressure
- 5% random irregular growth
**Candy Bubble:** No candy bubbles in Phase 1.
**Behavior Notes:**
- Do not fill the entire outer ring.
- Do not make a perfect square or circle of sticky candy.
- Leave some untouched spaces through hidden movement buffer logic.
- Avoid heavy trapping.
- Let players understand the hazard before it becomes punishing.
### Phase 2 — Middle Pressure and Bubble Disruption (1:002:00)
**Purpose:**
- Move the candy pressure inward.
- Make middle routes dangerous.
- Introduce candy bubbles as anti-camping threats.
- Force players to make route and Cleanser decisions.
**Normal Sticky Growth:**
- Growth tick every 3 seconds.
- 68 cells selected per tick.
- 1 second telegraph before cells become sticky.
- Growth mainly affects the Middle Layer.
- The Outer Layer can continue filling.
**Recommended growth distribution:**
- 50% Middle Layer
- 20% Outer Layer continuation
- 15% near-player pressure
- 10% existing sticky cluster expansion
- 5% random irregular growth
**Candy Bubble:**
- 2 bubbles total during Phase 2.
- Suggested spawn windows: Bubble 1 at 1:151:25, Bubble 2 at 1:401:50.
- Bubble starts as 1×1, grows for 2.53 seconds, explodes into 3×3 sticky area.
- Should target camping behavior, large untouched spaces, or overly comfortable positions.
**Behavior Notes:**
- Middle routes should start feeling unsafe.
- Some untouched movement spaces may still exist, but they are not guaranteed safe.
- Players should begin using Cleanser strategically.
- Previously untouched areas from Phase 1 can become valid targets after the phase changes.
Example: At 0:50, a player may be standing inside an untouched space that the algorithm has avoided targeting to prevent movement from breaking too early. At 1:05, after Phase 2 begins, that same space can become a valid target for normal sticky growth. If the player camps there and refuses to move or use Cleanser, the system may target that area.
### Phase 3 — Inner Survival Pressure (2:003:00)
**Purpose:**
- Create final survival tension.
- Pressure the inner arena.
- Force Cleanser usage.
- Make sabotage more dangerous.
- Create clutch escape moments.
**Normal Sticky Growth:**
- Growth tick every 3 seconds.
- 810 cells selected per tick.
- 1 second telegraph before cells become sticky.
- Growth can now affect Inner Layer, Middle Layer, and remaining untouched spaces.
**Recommended growth distribution:**
- 35% Inner Layer
- 25% Middle Layer
- 15% near-player pressure
- 15% existing sticky cluster expansion
- 10% random irregular growth
**Candy Bubble:**
- 3 bubbles total during Phase 3.
- Suggested spawn windows: Bubble 1 at 2:102:20, Bubble 2 at 2:302:40, Bubble 3 at 2:452:55.
- Bubble starts as 1×1, grows for 2.53 seconds, explodes into 3×3 sticky area.
- May target camping or large untouched spaces more aggressively than in Phase 2.
**Behavior Notes:**
- The arena should feel dangerous but not completely impossible.
- The final 2030 seconds may be more aggressive.
- Near-traps and forced Cleanser moments are allowed.
- Hidden movement buffer logic becomes weaker.
- Camping players should no longer be protected by untouched spaces.
## 14. Candidate Cell Selection
Every growth tick, the system must decide which SAFE cells should become TELEGRAPHED. To do this, the system creates a list of candidate cells. A candidate cell is a SAFE cell that is allowed to become sticky.
The system should not choose cells completely randomly. Each candidate cell should receive a score. Higher score means the cell is more likely to be selected for sticky growth. This score is called the Candidate Score.
### Candidate Score Purpose
The Candidate Score helps the candy growth feel:
- organic,
- semi-predictable,
- inward-moving,
- influenced by current phase,
- influenced by player behavior,
- not perfectly random,
- not perfectly readable,
- and less likely to break movement unfairly.
### Candidate Score Formula
```
CandidateScore =
LayerPriority
+ StickyNeighborScore
+ InwardPressureScore
+ PlayerPressureScore
+ ClusterGrowthScore
+ RoutePressureScore
+ CampingPressureScore
+ RandomNoise
+ MovementBufferPenalty
+ PathSafetyPenalty
+ RepetitionPenalty
```
## 15. Candidate Score Components
### LayerPriority
Checks if the cell is in the layer that should currently be pressured.
| Phase | Outer Layer | Middle Layer | Inner Layer |
|---|---|---|---|
| Phase 1 | +60 | +15 | -40 |
| Phase 2 | +20 | +60 | +5 |
| Phase 3 | +10 | +35 | +60 |
Purpose: Makes candy pressure move from outside to inside over time.
### StickyNeighborScore
Candy should prefer to grow near existing sticky candy. Check the 8 neighboring cells around the candidate cell.
- +8 for each neighboring STICKY cell
- Maximum possible: 8 neighbors × 8 = +64
Purpose: Creates organic cellular-automation growth. Sticky candy grows in clusters instead of appearing as isolated random noise.
### InwardPressureScore
Cells slightly closer to the center should become more likely as the match progresses.
| Phase | Value |
|---|---|
| Phase 1 | +0 to +10 |
| Phase 2 | +5 to +20 |
| Phase 3 | +10 to +30 |
Purpose: Helps candy naturally push inward without filling perfectly even rings.
### PlayerPressureScore
The system may pressure players, but should not always target directly under them.
- If candidate cell is 24 cells away from a player: +20
- If candidate cell is directly under a player before the final 30 seconds: -50
- If candidate cell is directly under a player during the final 30 seconds: +10, but only if telegraph is clear
Purpose: Pressures player movement without feeling like unfair direct targeting.
### ClusterGrowthScore
If a candidate cell connects two nearby sticky clusters, it can receive a bonus.
- +15 if selecting this cell expands a sticky cluster
- +25 if it connects two sticky clusters or helps close a weak route during Phase 2 or Phase 3
Purpose: Makes candy growth shape the arena meaningfully.
### RoutePressureScore
A cell can receive a bonus if it pressures a commonly used route.
- +10 to +25 if the cell is part of a common path, narrow lane, or high-traffic route
Purpose: Prevents players from relying on the same safe route forever.
### CampingPressureScore
This increases the chance that cells near camping players become targetable.
- +20 if a player has stayed inside the same 4×4 area for more than 5 seconds
- +40 if a player has stayed inside the same 4×4 area for more than 8 seconds
- +60 if a player has stayed inside the same 4×4 area for more than 10 seconds and has Cleanser available
Purpose: Allows the system to break camping behavior, especially when the player has a tool to escape but refuses to use it.
### RandomNoise
Add randomness so the growth is not too predictable.
- Random number between -20 and +20
Purpose: Keeps the candy pattern imperfect and alive.
### MovementBufferPenalty
This lowers the chance that a candidate cell is selected if that cell is part of a temporary hidden movement buffer. Movement buffers are not visible to players. They are not safe zones. They are only used by the algorithm to avoid breaking movement too early.
| Phase | Inside Buffer | Adjacent to Buffer |
|---|---|---|
| Phase 1 | -40 | -20 |
| Phase 2 | -20 | -10 |
| Phase 3 | -10 | 0 during final 30 seconds |
Purpose: Keeps some movement space open temporarily without promising safety to the player.
### PathSafetyPenalty
If selecting this cell would fully trap a player or delete the only nearby escape route too early, reduce its score heavily.
- -100 before final 30 seconds if the cell would fully trap a player
- -60 if the cell removes the last useful exit from a local safe cluster
- -20 if the cell makes a route too narrow too early
Purpose: Prevents the automatic system from feeling unfair.
### RepetitionPenalty
Avoid selecting the same area repeatedly.
- -30 if nearby cells were selected in the last tick
- -15 if the same local region has been targeted repeatedly within the last 10 seconds
Purpose: Avoids spammy, unfair, or visually boring growth.
## 16. Candidate Selection Process
Every normal sticky growth tick should follow this process:
1. Find all SAFE cells.
2. Remove invalid cells (BLOCKED, STICKY, BUBBLE_GROWING, already TELEGRAPHED, cells inside active bubble warning area unless intended).
3. Calculate Candidate Score for each valid SAFE cell.
4. Sort or weight candidates by score.
5. Select cells using weighted randomness. Higher-scoring cells have better chance, but lower-scoring cells can still occasionally be selected if valid.
6. Apply path safety check. If selected cells would unfairly trap players, replace some with safer candidates.
7. Apply movement buffer check. If selected cells delete too much hidden movement space too early, replace some.
8. Telegraph selected cells for 1 second.
9. Convert telegraphed cells to STICKY.
## 17. Hidden Movement Buffer System
The sticky growth algorithm should not visually show safe zones or protected areas to players. Some safe areas may be left untouched by the system temporarily, but this is only an underlying code behavior. It is not a player-facing mechanic. These areas are called Movement Buffer Zones.
### Important Rules
Movement Buffer Zones are:
- hidden from players,
- not visually marked,
- not guaranteed safe,
- not permanent,
- not a shield,
- not a reward,
- not a camping zone.
Players should not be able to clearly identify them as protected areas. The system may avoid targeting a Movement Buffer Zone for a short time, but it can target that same area later.
Example: At 0:50, a player may be standing in a safe cluster that the algorithm has avoided targeting so movement does not break too early. At 1:05, after the phase changes, that same cluster can become valid for sticky growth. If the player stays there too long and refuses to move or use Cleanser, the system may target that area with normal sticky growth or a candy bubble.
### Purpose
Movement Buffer Zones exist to:
- prevent unfair early traps,
- preserve basic movement flow,
- leave temporary gaps in sticky growth,
- avoid full-ring coverage,
- support Cleanser decision-making,
- prevent the arena from becoming broken too early.
They do not exist to protect camping players.
### Algorithm Behavior
The system should dynamically detect safe clusters during the round. A safe cluster is a connected group of SAFE cells. The algorithm should temporarily reduce the targeting priority of some safe clusters if removing them would make movement too broken. However, this reduction should decay over time.
**Recommended behavior:**
| Phase | Buffer Strength |
|---|---|
| Phase 1 | Stronger. Avoid deleting too many safe clusters in outer and middle arena. |
| Phase 2 | Weaker. May begin targeting previously untouched areas, especially if used for camping. |
| Phase 3 | Minimal. Can pressure most remaining safe clusters, especially during final 30 seconds. |
### Camping Override
If a player stays inside or near the same safe cluster for too long, the Movement Buffer should weaken or be removed.
- If a player remains within the same 4×4 area for more than 57 seconds: increase the chance that the area becomes targetable.
- If a player remains in the same area for more than 810 seconds: allow a candy bubble to spawn near that area.
### Movement Buffer Decay
- When a buffer is created, it starts with full penalty value.
- Every 5 seconds, reduce the penalty by 25%.
- When a phase changes, reduce all existing buffer penalties by 50%.
- During the final 30 seconds, remove most buffer penalties.
### Implementation Note
Movement Buffer Zones are not placed by level design. They are detected dynamically by the algorithm based on the current shape of the arena. The system should look for clusters of SAFE cells and temporarily reduce their chance to be selected if removing them would damage movement flow too much. The player should only experience this as natural-looking uneven candy growth, not as a visible protection system.
## 18. Path Safety Rule
The candy growth should pressure players, but automatic growth should avoid unfair instant traps too early. Before applying a group of selected sticky cells, the system should run a path safety check.
**Recommended check:**
For each active player:
1. Temporarily apply the selected sticky cells.
2. Check if the player still has reachable SAFE cells nearby.
3. Check if the player has at least one escape path or local movement option.
4. If the player would become fully trapped before the final 30 seconds, reject or replace some selected cells.
**Simple version:**
- Before final 30 seconds: Each active player should have at least one reachable safe region within 68 cells, unless they are already trapped or made a clear mistake.
- Final 30 seconds: The rule can become softer. Forced Cleanser moments are allowed.
**Important:** The path safety rule should not make the player immune. It only prevents the automatic candy growth from breaking movement too early.
## 19. Candy Bubble Candidate Logic
Candy bubbles use a different selection logic from normal sticky growth. Normal sticky growth is mainly about arena pressure. Candy bubbles are mainly about anti-camping and disruption.
**Bubble candidates should prefer:**
- players who have stayed in the same area too long,
- large untouched spaces,
- high-traffic safe areas,
- mission-heavy areas,
- contested areas with multiple players nearby,
- cells that are not already sticky.
**Bubble candidates should avoid:**
- spawning directly on top of a player without enough warning,
- spawning inside blocked cells,
- spawning too close to another recent bubble,
- spawning where the 3×3 explosion would hit mostly blocked or sticky cells,
- spawning where it creates unavoidable instant failure.
### Bubble Spawn Score
```
BubbleScore =
CampingScore
+ UntouchedAreaScore
+ PlayerClusterScore
+ MissionRouteScore
+ RandomNoise
+ DirectHitPenalty
+ RecentBubblePenalty
+ UnfairTrapPenalty
```
**Recommended values:**
| Component | Value |
|---|---|
| CampingScore | +40 if player in same 4×4 area >5s, +60 if >8s, +80 if >10s with Cleanser |
| UntouchedAreaScore | +30 if cell is inside or near large untouched safe cluster |
| PlayerClusterScore | +20 if 2+ players nearby |
| MissionRouteScore | +10 to +20 if area is important for scoring |
| RandomNoise | Random between -20 and +20 |
| DirectHitPenalty | -60 if bubble center is directly under a player |
| RecentBubblePenalty | -50 if area had a bubble recently |
| UnfairTrapPenalty | -100 if bubble would create unavoidable trap too early |
## 20. Telegraph System
### Normal Sticky Telegraph
Before a normal sticky cell appears, the selected cell becomes TELEGRAPHED. Telegraph duration is 1 second. The warning should be readable but quick.
**Suggested visuals:**
- candy cracks in the floor,
- syrup glow,
- small ground swelling,
- sticky sparkles,
- soft bubbling sound.
### Candy Bubble Telegraph
Candy bubbles have longer warning than normal sticky cells.
| Property | Value |
|---|---|
| Bubble grow duration | 2.53 seconds |
**Bubble visual stages:**
1. Small 1×1 candy bubble appears.
2. Bubble grows larger and becomes brighter.
3. Bubble shakes or pulses.
4. Bubble explodes into 3×3 sticky area.
The 3×3 explosion area should be visually warned before impact.
**Suggested visuals:**
- pulsing 3×3 shadow,
- circular candy glow around the bubble,
- syrup splash preview,
- final flash before explosion.
## 21. Cleanser Power-Up
| Property | Value |
|---|---|
@@ -142,7 +758,18 @@ Players feel like they are racing through a colorful board-game arena while the
| Cannot activate | While stunned |
| Activation delay | ~0.3 seconds |
## 12. Smack / Sabotage Mechanic
### Cleanser Design Purpose
Cleanser makes dangerous areas playable. It allows players to:
- escape sticky traps,
- reopen movement routes,
- cut through sticky areas,
- survive final pressure,
- take risks near sticky zones.
The candy growth algorithm can be slightly more aggressive because skilled players can use Cleanser to recover.
## 22. Smack / Sabotage Mechanic
| Property | Value |
|---|---|
@@ -158,43 +785,81 @@ Players feel like they are racing through a colorful board-game arena while the
- Two players activate smack simultaneously, both in range → **both smacked, both stunned, no push, both bars consumed**
- Stun duration for clash: **1.0 second**
## 13. Win Condition
## 23. Win Condition
- Highest score at end of 3 minutes wins
- Trapped/eliminated players keep earned score
- Surviving players receive survival bonus
- Survival bonus should be small enough that scoring still matters, large enough that surviving feels valuable
## 14. Flow Phases
## 24. Flow Phases
### Phase 1 — Open Arena (0:001:00)
- Collect tiles, understand mission
- Slow candy pressure (1×1, 1×2 mostly)
- Let players understand the mission.
- Let players collect tiles.
- Introduce outer candy pressure slowly.
- Show that the arena is closing inward.
**Candy behavior:**
- Normal sticky growth every 3 seconds.
- 46 cells per tick.
- Mostly Outer Layer.
- 0 bubbles.
**Player feeling:** "The edge is getting dangerous. I should start moving inward soon."
### Phase 2 — Route Pressure (1:002:00)
- Candy shapes arena
- Smack becomes dangerous
- Cleanser used strategically
- 1×2 and 2×2 shots common, targeting near players/paths
- Candy begins shaping the middle arena.
- Smack becomes more dangerous.
- Players start using Cleanser strategically.
- Candy bubbles disrupt camping.
- Previously untouched spaces can become targetable.
**Candy behavior:**
- Normal sticky growth every 3 seconds.
- 68 cells per tick.
- Mostly Middle Layer.
- 2 total candy bubbles.
**Player feeling:** "The safe paths are breaking. I need to choose a route, use Cleanser, or move before I get stuck."
### Phase 3 — Survival Endgame (2:003:00)
- Arena mostly sticky, safe zones limited
- Secure final points or focus survival
- 2×2 shots frequent, rare 1×3 line shots possible
- Aggressive route-blocking allowed
- The arena is mostly sticky.
- Safe movement is limited.
- Players either secure final points or focus survival.
- Bubbles and sticky growth force final movement.
## 15. Balance Recommendations (Starting Values)
**Candy behavior:**
- Normal sticky growth every 3 seconds.
- 810 cells per tick.
- Inner Layer pressure active.
- 3 total candy bubbles.
- Final 2030 seconds may be more aggressive.
**Player feeling:** "I need to survive, escape, or make one last scoring play."
## 25. Balance Recommendations (Starting Values)
| System | Value |
|---|---|
| Arena size | 20×20 |
| Arena size | 24×24 |
| NPC size | 3×3 center |
| Playable cells | 567 |
| Round duration | 180 seconds |
| Cannon interval | 5 seconds |
| Telegraph duration | 1 second |
| Target sticky coverage | 80% playable cells |
| Normal sticky growth interval | 3 seconds |
| Normal sticky telegraph | 1 second |
| Phase 1 growth | 46 cells per tick |
| Phase 2 growth | 68 cells per tick |
| Phase 3 growth | 810 cells per tick |
| Phase 1 bubbles | 0 total |
| Phase 2 bubbles | 2 total |
| Phase 3 bubbles | 3 total |
| Bubble grow duration | 2.53 seconds |
| Bubble explosion size | 3×3 |
| Target final sticky coverage | 70%75% |
| Smack charge window | 3 seconds |
| Smack push distance | 3 cells |
| Smack energy refill | 8 seconds |
@@ -202,33 +867,88 @@ Players feel like they are racing through a colorful board-game arena while the
| Cleanser unlock | Every 2 completed missions |
| Cleanser movement limit | 5 cells |
| Cleanser inventory limit | 1 |
| Movement Buffer | Hidden algorithmic system only. Not visible to players. |
## 16. Risk Areas & Solutions
## 26. Coverage Math
| Metric | Value |
|---|---|
| Arena | 24×24 = 576 total cells |
| Center NPC | 3×3 = 9 blocked cells |
| Playable cells | 567 |
### Normal Sticky Growth
| Phase | Calculation | Sticky Attempts |
|---|---|---|
| Phase 1 | 20 ticks × 46 cells | 80120 |
| Phase 2 | 20 ticks × 68 cells | 120160 |
| Phase 3 | 20 ticks × 810 cells | 160200 |
| **Total** | | **360480** |
### Candy Bubble Growth
| Phase | Calculation | Sticky Attempts |
|---|---|---|
| Phase 2 | 2 bubbles × 9 cells | 18 |
| Phase 3 | 3 bubbles × 9 cells | 27 |
| **Total** | | **45** |
### Combined
| Metric | Value |
|---|---|
| Total sticky attempts | 405525 |
| Estimated real final coverage | 397 (70%) to 425 (75%) |
| Recommended target | 70%75% final sticky coverage |
If playtest coverage goes above 78% too often, reduce Phase 3 growth from 810 cells to 79 cells. If playtest coverage feels too low, increase Phase 1 and Phase 2 growth slightly before increasing Phase 3.
## 27. Risk Areas & Solutions
| Risk | Solution |
|---|---|
| Arena impossible too early | Reduce early 2×2 shots; preserve escape paths |
| Smack too strong | Increase refill to 10s; reduce push to 2 cells; brief invuln after smack |
| Cleanser too weak | Increase movement to 6 cells; clear adjacent cells |
| Cannon feels random/unfair | Readable telegraphs; avoid targeting same player; avoid full enclosure before final 30s |
| Arena becomes impossible too early | Reduce Phase 2/3 growth; strengthen MovementBufferPenalty; increase bubble spacing; strengthen path safety checks |
| Players camp center too easily | Add Inner Layer warning pops in Phase 2; let bubbles target camping; reduce MovementBufferPenalty near center; increase CampingPressureScore |
| Growth feels too random | Increase StickyNeighborScore; increase LayerPriority; reduce RandomNoise; make candy prefer adjacent sticky cells |
| Growth feels too predictable | Increase RandomNoise; add random irregular growth; avoid full-ring telegraphs; make movement buffers decay faster |
| Candy bubbles feel unfair | Use 2.53s grow time; penalize direct player spawn; add bubble path safety check; add RecentBubblePenalty |
| Cleanser feels too weak | Increase movement from 5 to 6 cells; give cleansed cells temporary protection; reduce Phase 3 growth; reduce final coverage target |
| Movement buffer feels like hidden protection | Increase buffer decay; reduce buffer strength after phase changes; increase CampingPressureScore; allow bubbles to target large untouched spaces; remove most penalties in final 30 seconds |
## 17. Prototype Setup
## 28. Prototype Setup
- Arena: 20×20
- NPC: 3×3 center
- Players: 4
- Arena: 24×24
- NPC: 3×3 center blocked area
- Players: 4 first, then test 68
- Round time: 3 minutes
- Cannon interval: 5 seconds
- Volley size: 5 shots
- Telegraph: 1 second
- Impact pattern as defined in Phase-Based table above
- Normal sticky growth: Every 3 seconds
- Normal telegraph: 1 second
- Phase 1 (0:001:00): 46 cells/tick, Mostly Outer Layer, 0 bubbles
- Phase 2 (1:002:00): 68 cells/tick, Mostly Middle Layer, 2 candy bubbles
- Phase 3 (2:003:00): 810 cells/tick, Inner Layer pressure, 3 candy bubbles
- Bubble grow duration: 2.53 seconds
- Bubble explosion: 3×3 sticky area
- Movement Buffer: Hidden dynamic algorithm only. Not visually shown. Not guaranteed safe. Decays over time. Can be overridden by camping. Mostly removed during final 30 seconds.
- Target final sticky coverage: 70%75% of playable cells
- Smack refill: 8s / window: 3s / push: 3 cells / stun: 1s
- Cleanser: every 2 missions / 5 cells movement / hold 1
## 18. Design Notes
## 29. Design Notes
- Mode is about **choosing** when to score, sabotage, save Cleanser, or abandon a mission
- Cannon creates **pressure**, not instant failure
- Smack creates **player-driven danger**
- Cleanser gives **recovery** and rewards brave play near danger
- Emotional curve: playful collection → tactical routing → chaotic survival
The strongest version of this mode is not about memorizing safe zones. It is about reading the candy flow, choosing when to score, when to move inward, when to use Cleanser, when to abandon a route, and when to sabotage other players.
- The candy growth should create **pressure**, not instant failure.
- The bubble system should create **disruption**, not become the main source of sticky coverage.
- The movement buffer system should preserve **playability**, not protect camping.
- The Cleanser should make **risky play possible**.
The ideal gameplay loop is:
1. Collect while the arena is still open.
2. Notice the outer candy pressure.
3. Move inward through broken routes.
4. Use temporary untouched spaces without trusting them forever.
5. Avoid or escape candy bubbles.
6. Use Cleanser to survive or create a route.
7. Sabotage players into sticky danger.
8. Survive the final inward collapse.
+11
View File
@@ -50,3 +50,14 @@ theme_override_constants/outline_size = 6
theme_override_fonts/font = ExtResource("1_font")
text = "Cleanser: 0"
horizontal_alignment = 1
[node name="SlowMoLabel" type="Label" parent="TopContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(0.3, 0.5, 1.0, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 4
theme_override_fonts/font = ExtResource("1_font")
text = "SLOW-MO"
horizontal_alignment = 1
visible = false
+88 -67
View File
@@ -70,25 +70,23 @@ var is_carrying_tekton: bool = false:
emit_signal("tekton_carried_changed", value)
# Visual/Logic side effects if any
var is_attack_mode: bool = false:
var is_charged_strike: bool = false:
set(value):
if is_attack_mode == value:
return # Prevent infinite recursion / redundant updates
if is_charged_strike == value:
return
is_attack_mode = value
if is_attack_mode:
attack_mode_timer = MAX_ATTACK_MODE_TIME
is_charged_strike = value
if is_charged_strike:
charged_strike_timer = MAX_CHARGED_STRIKE_TIME
_refresh_player_visuals()
# Sync to others if we are the authority
if is_multiplayer_authority() and can_rpc():
rpc("sync_attack_mode", is_attack_mode)
rpc("sync_charged_strike", is_charged_strike)
@rpc("any_peer", "call_local", "reliable")
func sync_attack_mode(state: bool):
# We WANT to trigger the setter to apply visuals on clients
# Using self.var triggers setter in GDScript
is_attack_mode = state
func sync_charged_strike(state: bool):
is_charged_strike = state
@export var is_bot: bool = false
@@ -248,6 +246,9 @@ func _ready():
# Character Pointer Visibility
# Visible to all human players. Green for local player, Red for others.
var pointer = get_node_or_null("CharacterPointer")
# === Dynamically load new Dasher animations ===
_load_dasher_animations()
if pointer:
pointer.visible = true
@@ -372,6 +373,47 @@ func _init_floor_spawn_anchor():
if floor_spawn_top:
floor_spawn_top.reparent(floor_spawn_anchor, false)
func _load_dasher_animations():
"""Dynamically loads dasher animations from GLB files and adds them to the AnimationPlayer."""
if not anim_player: return
var anim_library = anim_player.get_animation_library("animation-pack")
if not anim_library:
anim_library = AnimationLibrary.new()
anim_player.add_animation_library("animation-pack", anim_library)
var dasher_files = [
{"path": "res://assets/characters/dashers/dasher_getting_hit.glb", "name": "dasher_getting_hit"},
{"path": "res://assets/characters/dashers/dasher_hit.glb", "name": "dasher_hit"},
{"path": "res://assets/characters/dashers/dasher_hold.glb", "name": "dasher_hold"},
{"path": "res://assets/characters/dashers/dasher_put.glb", "name": "dasher_put"},
{"path": "res://assets/characters/dashers/dasher_stun.glb", "name": "dasher_stun"},
{"path": "res://assets/characters/dashers/dasher_take.glb", "name": "dasher_take"}
]
for file_data in dasher_files:
var gltf_doc = GLTFDocument.new()
var gltf_state = GLTFState.new()
var error = gltf_doc.append_from_file(file_data.path, gltf_state)
if error == OK:
var anim_player_node = gltf_state.get_animation_player(0)
# Godot's GLTF importer creates an AnimationPlayer inside the scene
var scene = gltf_doc.generate_scene(gltf_state)
if scene:
var scene_anim_player = scene.find_child("AnimationPlayer", true, false)
if scene_anim_player:
var libs = scene_anim_player.get_animation_library_list()
for lib_name in libs:
var temp_lib = scene_anim_player.get_animation_library(lib_name)
for anim_name in temp_lib.get_animation_list():
var anim = temp_lib.get_animation(anim_name)
if not anim_library.has_animation(file_data.name):
anim_library.add_animation(file_data.name, anim)
scene.queue_free()
print("[Player] Dasher animations loaded into 'animation-pack'.")
@onready var floor_spawn_bot: AnimatedSprite3D = $floor_spawn_bot
@onready var floor_spawn_top: AnimatedSprite3D = $floor_spawn_top
@onready var vfx_scatter_knock: AnimatedSprite3D = $scatter_knock
@@ -874,10 +916,10 @@ func _refresh_player_visuals():
color_to_apply = Color.CYAN # Stop n Go Freeze
elif is_slowed:
color_to_apply = Color(0.6, 0.8, 1.0) # Slowed / Icy Blue
elif is_attack_mode:
color_to_apply = Color(1.0, 0.5, 0.5) # Attack Mode (Red Tint)
elif is_carrying_tekton or is_knock_mode:
color_to_apply = Color(1.0, 1.0, 0.0) # Carrying or Knocking (Yellow)
elif is_charged_strike:
color_to_apply = Color(1.0, 0.5, 0.5) # Charged Strike (Red Tint)
elif is_carrying_tekton:
color_to_apply = Color(1.0, 1.0, 0.0) # Carrying (Yellow)
alpha_to_apply = 0.5 # 50% opacity when carrying Tekton
elif immunity_timer > 0:
color_to_apply = Color(0.5, 1.0, 0.5) # Immunity (Light Green)
@@ -905,7 +947,7 @@ func update_rank_visuals(rank: int):
if not pos_label:
return
if rank <= 3:
if rank <= 4:
pos_label.visible = true
if race_manager:
pos_label.text = race_manager.get_ordinal_string(rank)
@@ -913,9 +955,10 @@ func update_rank_visuals(rank: int):
pos_label.text = str(rank)
match rank:
1: pos_label.modulate = Color(0.85, 0.0, 0.0) # Red
2: pos_label.modulate = Color(0.0, 0.0, 1.0) # Blue
3: pos_label.modulate = Color(1.0, 0.9, 0.0) # Yellow
1: pos_label.modulate = Color(1.0, 0.84, 0.0) # Gold
2: pos_label.modulate = Color(0.75, 0.75, 0.75) # Silver
3: pos_label.modulate = Color(0.8, 0.5, 0.2) # Bronze
4: pos_label.modulate = Color(0.5, 0.5, 0.5) # Grey
else:
pos_label.visible = false
@@ -941,8 +984,8 @@ var slow_timer: float = 0.0
var tekton_carry_timer: float = 0.0
const MAX_TEKTON_CARRY_TIME: float = 3.0
var attack_mode_timer: float = 0.0
const MAX_ATTACK_MODE_TIME: float = 5.0
var charged_strike_timer: float = 0.0
const MAX_CHARGED_STRIKE_TIME: float = 5.0
@rpc("any_peer", "call_local")
func apply_stagger(duration: float = 1.5):
@@ -1220,7 +1263,7 @@ func attempt_target_action(target_index: int):
inventory_ui.deselect()
func activate_powerup(effect_id: int):
if is_carrying_tekton or is_knock_mode or is_attack_mode:
if is_carrying_tekton or is_charged_strike:
NotificationManager.send_message(self, "Cannot use Power-Up right now!", NotificationManager.MessageType.WARNING)
return
@@ -1253,7 +1296,7 @@ func activate_powerup(effect_id: int):
func activate_held_powerup():
"""Finds whichever powerup is currently held and activates it."""
if is_carrying_tekton or is_knock_mode or is_attack_mode:
if is_carrying_tekton or is_charged_strike:
NotificationManager.send_message(self, "Cannot use Power-Up right now!", NotificationManager.MessageType.WARNING)
return
@@ -1299,15 +1342,14 @@ func _process(delta):
if movement_manager:
movement_manager._process(delta)
# Attack/Knock Mode Expiration Timer
if is_multiplayer_authority() and (is_attack_mode or is_knock_mode):
if attack_mode_timer > 0:
attack_mode_timer -= delta
if attack_mode_timer <= 0:
attack_mode_timer = 0.0
is_attack_mode = false
is_knock_mode = false
NotificationManager.send_message(self, "Knock Mode Expired!", NotificationManager.MessageType.WARNING)
# Charged Strike Expiration Timer
if is_multiplayer_authority() and is_charged_strike:
if charged_strike_timer > 0:
charged_strike_timer -= delta
if charged_strike_timer <= 0:
charged_strike_timer = 0.0
is_charged_strike = false
NotificationManager.send_message(self, "Charged Strike Expired!", NotificationManager.MessageType.WARNING)
if powerup_manager:
powerup_manager.reset_boost()
@@ -2393,8 +2435,8 @@ func sync_snatch_tekton(carrier_path: NodePath, tekton_path: NodePath):
tekton_carry_timer = 0.0
# Visual/Logic side effects
if is_attack_mode:
is_attack_mode = false
if is_charged_strike:
is_charged_strike = false
SfxManager.play("pick_up_tekton_roaming")
play_pickup_animation()
@@ -2414,9 +2456,9 @@ func sync_grab_tekton(tekton_path: NodePath):
self.is_carrying_tekton = true
tekton.set_carried(true, self )
# Disposed of AttackMode upon grab
if is_attack_mode:
is_attack_mode = false
# Disposed of Charged Strike upon grab
if is_charged_strike:
is_charged_strike = false
SfxManager.play("pick_up_tekton_roaming")
play_pickup_animation()
@@ -2558,38 +2600,17 @@ func sync_drop_tekton():
print("[Player %s] Dropped Tekton at %s" % [name, current_position])
# is_attack_mode is already declared at top of file (or inherited?)
# Keeping is_knock_mode here for now or moving it up would be better, but let's just fix the error first.
var is_knock_mode: bool = false:
set(value):
if is_knock_mode == value: return
is_knock_mode = value
if is_knock_mode:
attack_mode_timer = MAX_ATTACK_MODE_TIME
_refresh_player_visuals()
func enter_attack_mode():
func enter_charged_strike():
if not is_multiplayer_authority(): return
if is_invisible:
NotificationManager.send_message(self , "Cannot enter Attack Mode while in Ghost mode!", NotificationManager.MessageType.WARNING)
NotificationManager.send_message(self , "Cannot use Charged Strike while in Ghost mode!", NotificationManager.MessageType.WARNING)
return
is_attack_mode = true
is_knock_mode = false # Mutually exclusive
NotificationManager.send_message(self , "Attack Mode ACTIVATED (Red)", NotificationManager.MessageType.POWERUP)
update_active_player_indicator()
func enter_knock_mode():
if not is_multiplayer_authority(): return
if is_invisible:
NotificationManager.send_message(self , "Cannot enter Knock Mode while in Ghost mode!", NotificationManager.MessageType.WARNING)
return
is_knock_mode = true
is_attack_mode = false # Mutually exclusive
NotificationManager.send_message(self , "Knock Mode ACTIVATED (Yellow)", NotificationManager.MessageType.POWERUP)
is_charged_strike = true
NotificationManager.send_message(self , "Charged Strike ACTIVATED (Red)", NotificationManager.MessageType.POWERUP)
update_active_player_indicator()
func update_active_player_indicator():
@@ -2619,8 +2640,8 @@ func knock_tekton():
if not is_multiplayer_authority() or is_frozen or is_stop_frozen or is_invisible:
return
# Requirement: Full Powerup Bar (or we are already in knock mode)
if not is_knock_mode and (not powerup_manager or not powerup_manager.can_use_special()):
# Requirement: Full Powerup Bar (or we are already charged)
if not is_charged_strike and (not powerup_manager or not powerup_manager.can_use_special()):
NotificationManager.send_message(self , "Need Full Boost to Knock!", NotificationManager.MessageType.WARNING)
return
@@ -2633,8 +2654,8 @@ func knock_tekton():
if is_multiplayer_authority():
rpc("sync_knock_tekton", tekton.get_path())
# Reset Knock Mode after successful hit
is_knock_mode = false
# Reset Charged Strike Mode after successful hit
is_charged_strike = false
NotificationManager.send_message(self , "Knock Successful!", NotificationManager.MessageType.POWERUP)
update_active_player_indicator()
else:
+5
View File
@@ -126,6 +126,10 @@ func _on_game_mode_changed(mode: String) -> void:
lobby.game_mode_text_label.text = mode
lobby._update_settings_visibility()
# Update area visual immediately when game mode changes
if lobby.area_name_label:
lobby.area_name_label.text = LobbyManager.get_selected_area()
func _on_sng_update(_val: int = 0) -> void:
if not lobby.sng_go_option: return
var go_idx = [10, 15, 25].find(LobbyManager.sng_go_duration)
@@ -247,6 +251,7 @@ func _on_character_changed(_player_id: int, _character_name: String) -> void:
func _on_area_changed(area_name: String) -> void:
if lobby.area_name_label: lobby.area_name_label.text = area_name
lobby._sync_room_profile_card()
func _update_player_slots() -> void:
if not lobby.multiplayer.has_multiplayer_peer():
+17 -1
View File
@@ -442,17 +442,33 @@ func logout() -> void:
# =============================================================================
func _connect_socket() -> bool:
if not NakamaManager.session:
push_error("[AuthManager] Socket connection failed: no Nakama session")
return false
if NakamaManager.socket and NakamaManager.socket.is_connected_to_host():
if not multiplayer.has_multiplayer_peer() and NakamaManager.bridge:
multiplayer.set_multiplayer_peer(NakamaManager.bridge.multiplayer_peer)
NakamaManager.connected_to_nakama.emit()
return true
if NakamaManager.socket:
NakamaManager.socket.close()
NakamaManager.socket = null
NakamaManager.socket = Nakama.create_socket_from(NakamaManager.client)
var result = await NakamaManager.socket.connect_async(NakamaManager.session)
if result.is_exception():
push_error("[AuthManager] Socket connection failed: " + result.get_exception().message)
var exception = result.get_exception()
var error_message = "Socket connection failed"
if exception and not exception.message.is_empty():
error_message = exception.message
elif exception and exception.status_code >= 0:
error_message = "Socket connection failed with error code %s" % exception.status_code
push_error("[AuthManager] " + error_message)
NakamaManager.socket.close()
NakamaManager.socket = null
return false
# Initialize multiplayer bridge
+74 -25
View File
@@ -99,6 +99,10 @@ const SMACK_CHARGE_WINDOW: float = 3.0
var player_mission_completions: Dictionary = {} # player_id → int
var player_cleansers: Dictionary = {} # player_id → int (0 or 1)
var cleanser_active: Dictionary = {} # player_id → true when immunity active
var cleanser_cells_left: Dictionary = {} # player_id → int (cells remaining)
const CLEANSER_MAX_CELLS: int = 5
const CLEANSER_ACTIVATION_DELAY: float = 0.3
# =============================================================================
# Trapped Players
@@ -131,6 +135,7 @@ var phase_label: Label
var cleanser_label: Label
var cleanser_icon: TextureRect
var cleanser_count: int = 0
var slowmo_label: Label
var _gauntlet_hud_scene: PackedScene = preload("res://scenes/gauntlet_hud.tscn")
# =============================================================================
@@ -763,13 +768,13 @@ func clear_sticky_cell(pos: Vector2i) -> void:
main_scene.rpc("sync_grid_item", pos.x, 2, pos.y, -1)
func _try_use_cleanser() -> void:
"""Local player attempts to use Cleanser on adjacent sticky cells."""
"""Local player attempts to activate Cleanser for 5-cell sticky immunity."""
var local_pid = multiplayer.get_unique_id()
var count = player_cleansers.get(local_pid, 0)
if count <= 0:
return
# Find local player
# Block activation during stun
var all_players = get_tree().get_nodes_in_group("Players")
var local_player = null
for p in all_players:
@@ -777,41 +782,75 @@ func _try_use_cleanser() -> void:
if pid == local_pid:
local_player = p
break
if not local_player or not gridmap:
if not local_player:
return
if local_player.get("is_frozen") or local_player.get("is_stop_frozen"):
return
# Already active
if cleanser_active.has(local_pid):
return
# Get player grid position
var player_pos = local_player.global_position
var grid_pos = Vector2i(int(player_pos.x), int(player_pos.z))
# 0.3s activation delay
await get_tree().create_timer(CLEANSER_ACTIVATION_DELAY).timeout
# Clear sticky cells in 3x3 area around player
var cleared_any = false
for dx in range(-1, 2):
for dz in range(-1, 2):
var check_pos = grid_pos + Vector2i(dx, dz)
if sticky_cells.has(check_pos):
if multiplayer.is_server():
clear_sticky_cell(check_pos)
else:
rpc("rpc_use_cleanser", check_pos)
cleared_any = true
# Re-validate after delay
if not is_instance_valid(local_player):
return
if local_player.get("is_frozen") or local_player.get("is_stop_frozen"):
return
if cleared_any:
# Consume cleanser
# Activate cleanser immunity
cleanser_active[local_pid] = true
cleanser_cells_left[local_pid] = CLEANSER_MAX_CELLS
# Consume cleanser from inventory
player_cleansers[local_pid] = 0
update_cleanser_ui(0)
# Sync to server/clients
if not multiplayer.is_server() and _can_rpc():
rpc("rpc_activate_cleanser", local_pid)
elif multiplayer.is_server():
if _can_rpc():
rpc("sync_cleanser_count", local_pid, 0)
# Trigger slow-mo for dramatic effect
if multiplayer.is_server():
trigger_slowmo()
else:
rpc("rpc_trigger_slowmo")
# Notify server if we're a client
if not multiplayer.is_server() and _can_rpc():
rpc("rpc_consume_cleanser", local_pid)
elif multiplayer.is_server():
# Sync to all clients
NotificationManager.send_message(local_player, "Cleanser Active! (5 cells)", NotificationManager.MessageType.POWERUP)
func deactivate_cleanser(player_id: int) -> void:
"""Deactivate cleanser immunity for a player."""
cleanser_active.erase(player_id)
cleanser_cells_left.erase(player_id)
func is_cleanser_active(player_id: int) -> bool:
"""Check if a player has active cleanser immunity."""
return cleanser_active.has(player_id)
func use_cleanser_cell(player_id: int) -> bool:
"""Use one cleanser cell. Returns true if still active, false if exhausted."""
if not cleanser_active.has(player_id):
return false
cleanser_cells_left[player_id] -= 1
if cleanser_cells_left[player_id] <= 0:
deactivate_cleanser(player_id)
return false
return true
@rpc("any_peer", "call_local", "reliable")
func rpc_activate_cleanser(pid: int) -> void:
"""RPC for clients to activate cleanser on server."""
if multiplayer.is_server():
if not cleanser_active.has(pid):
cleanser_active[pid] = true
cleanser_cells_left[pid] = CLEANSER_MAX_CELLS
player_cleansers[pid] = 0
if _can_rpc():
rpc("sync_cleanser_count", local_pid, 0)
rpc("sync_cleanser_count", pid, 0)
@rpc("any_peer", "call_local", "reliable")
func rpc_use_cleanser(pos: Vector2i) -> void:
@@ -848,6 +887,9 @@ func trigger_slowmo(duration: float = 4.0) -> void:
# Show visual overlay
if main_scene and main_scene.has_node("Camera3D200"):
_show_slowmo_overlay()
# Show slow-mo HUD label
if slowmo_label:
slowmo_label.visible = true
if _can_rpc():
rpc("sync_slowmo_start", duration)
@@ -855,6 +897,9 @@ func _end_slowmo() -> void:
slowmo_active = false
Engine.time_scale = 1.0
_hide_slowmo_overlay()
# Hide slow-mo HUD label
if slowmo_label:
slowmo_label.visible = false
if _can_rpc():
rpc("sync_slowmo_end")
@@ -890,6 +935,8 @@ func sync_slowmo_start(duration: float) -> void:
slowmo_timer = duration
Engine.time_scale = SLOWMO_SCALE
_show_slowmo_overlay()
if slowmo_label:
slowmo_label.visible = true
@rpc("authority", "call_local", "reliable")
func sync_slowmo_end() -> void:
@@ -907,6 +954,7 @@ func _setup_hud() -> void:
phase_label = hud_layer.get_node("TopContainer/PhaseLabel")
cleanser_icon = hud_layer.get_node("BottomContainer/CleanserHBox/CleanserIcon")
cleanser_label = hud_layer.get_node("BottomContainer/CleanserHBox/CleanserLabel")
slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel")
_generate_cleanser_icon()
func _generate_cleanser_icon() -> void:
@@ -995,6 +1043,7 @@ func _on_goal_count_updated(peer_id: int, count: int) -> void:
player_cleansers[peer_id] = 0
if player_cleansers[peer_id] < 1:
player_cleansers[peer_id] = 1
emit_signal("cleanser_granted", peer_id)
print("[Gauntlet] Player %d granted Cleanser (mission %d)" % [peer_id, completions])
# Respawn mission tiles in non-sticky locations
+25 -1
View File
@@ -725,16 +725,40 @@ func set_game_mode(mode: String) -> void:
rpc("sync_game_mode", mode)
_update_available_areas(mode)
# Only force switch the area if the selected area is NOT valid for this mode
if selected_area not in available_areas:
set_area(available_areas[0])
else:
# Important: even if the area is technically in the list, if they just clicked Free Mode
# we should default them to Free Mode Area if they were on Stop n Go Area before.
if mode == "Free Mode" and "Free Mode Area" in available_areas:
set_area("Free Mode Area")
elif mode == "Stop n Go" and "Stop n Go Area" in available_areas:
set_area("Stop n Go Area")
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
set_area("Tekton Doors Area")
elif mode == "Gauntlet" and "Candy Pump Arena" in available_areas:
set_area("Candy Pump Arena")
@rpc("authority", "call_local", "reliable")
func sync_game_mode(mode: String) -> void:
"""Sync game mode selection from host to clients."""
game_mode = mode
_update_available_areas(mode)
if selected_area not in available_areas:
# Try to smart-match the client's local area to the mode as well so their UI matches
if mode == "Free Mode" and "Free Mode Area" in available_areas:
selected_area = "Free Mode Area"
elif mode == "Stop n Go" and "Stop n Go Area" in available_areas:
selected_area = "Stop n Go Area"
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
selected_area = "Tekton Doors Area"
elif mode == "Gauntlet" and "Candy Pump Arena" in available_areas:
selected_area = "Candy Pump Arena"
elif selected_area not in available_areas:
selected_area = available_areas[0]
emit_signal("game_mode_changed", mode)
func start_game(force: bool = false) -> void:
+20 -6
View File
@@ -143,9 +143,9 @@ func simple_move_to(grid_position: Vector2i) -> bool:
print("[Move] Failed: Player is trapped in a sticky cell")
return false
# Check for Tekton interaction (Knock Mode)
# If moving into a Tekton's space while in Knock Mode, trigger knock
if player.get("is_knock_mode"):
# Check for Tekton interaction (Charged Strike Mode)
# If moving into a Tekton's space while Charged, trigger knock
if player.get("is_charged_strike"):
# Find Tekton at grid_position
var tektons = player.get_tree().get_nodes_in_group("Tektons")
for t in tektons:
@@ -154,8 +154,15 @@ func simple_move_to(grid_position: Vector2i) -> bool:
player.knock_tekton()
return false # Don't move into the tile, just knock
# If moving into a sticky cell, trigger trap
# If moving into a sticky cell, trigger trap (unless cleanser active)
if gm and gm.is_active and gm.is_sticky_cell(grid_position):
var pid = player.get("peer_id") if "peer_id" in player else -1
if pid != -1 and gm.is_cleanser_active(pid):
# Cleanser immunity: clear sticky cell, use one cell, don't trap
gm.clear_sticky_cell(grid_position)
gm.use_cleanser_cell(pid)
print("[Move] Cleanser cleared sticky cell at %s (%d cells left)" % [grid_position, gm.cleanser_cells_left.get(pid, 0)])
else:
print("[Move] Player stepping into sticky cell at %s" % grid_position)
movement_queue.clear()
if player.is_multiplayer_authority() or multiplayer.is_server():
@@ -212,9 +219,9 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
var att_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
has_smack = gm_for_smack.has_smack_charged(att_pid)
if (not player.get("is_attack_mode") and not has_smack) or player.get("is_invisible"):
if (not player.get("is_charged_strike") and not has_smack) or player.get("is_invisible"):
# Standard bumping effect (Visual only)
print("[Move] Push blocked: Not in attack mode or is Ghost (%s trying to push %s)" % [player.name, other_player.name])
print("[Move] Push blocked: Not charged or is Ghost (%s trying to push %s)" % [player.name, other_player.name])
if _can_rpc():
player.rpc("sync_bump", target_pos, true) # Soft bump
elif player.has_method("sync_bump"):
@@ -334,6 +341,13 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
if main_sticky and main_sticky.get("gauntlet_manager"):
var gm_sticky = main_sticky.gauntlet_manager
if gm_sticky.is_active and gm_sticky.is_sticky_cell(pushed_to_pos):
var push_pid = other_player.get("peer_id") if "peer_id" in other_player else -1
if push_pid != -1 and gm_sticky.is_cleanser_active(push_pid):
# Cleanser immunity: clear sticky cell, use one cell
gm_sticky.clear_sticky_cell(pushed_to_pos)
gm_sticky.use_cleanser_cell(push_pid)
print("[Move] Cleanser cleared push-into-sticky at %s" % pushed_to_pos)
else:
print("[Move] Player pushed into sticky cell at %s" % pushed_to_pos)
if multiplayer.is_server() or other_player.is_multiplayer_authority():
gm_sticky._trap_player(other_player)
+6 -6
View File
@@ -159,19 +159,19 @@ func use_special_effect() -> bool:
# Restriction: Cannot use attack mode while carrying a Tekton or in Ghost mode
if player.is_carrying_tekton:
NotificationManager.send_message(player, "Cannot enter Attack Mode while carrying a Tekton!", NotificationManager.MessageType.WARNING)
NotificationManager.send_message(player, "Cannot use Charged Strike while carrying a Tekton!", NotificationManager.MessageType.WARNING)
return false
if player.get("is_invisible"):
NotificationManager.send_message(player, "Cannot enter Attack Mode while in Ghost mode!", NotificationManager.MessageType.WARNING)
NotificationManager.send_message(player, "Cannot use Charged Strike while in Ghost mode!", NotificationManager.MessageType.WARNING)
return false
# Enable Attack Mode explicitly
player.is_attack_mode = true
# Do NOT consume boost here. Boost acts as "fuel" for the attacks.
# Notification check handled by caller or signal
if player.has_method("enter_charged_strike"):
player.enter_charged_strike()
return true
return false
func consume_boost(amount: float):
"""Consume a specific amount of boost."""
current_boost -= amount
+12 -1
View File
@@ -289,7 +289,9 @@ func get_action_display(action_key: String) -> String:
"grab": "ctrl_grab",
"use_powerup": "ctrl_use_powerup",
"tekton_grab": "ctrl_tekton_grab",
"action_grab_tekton": "ctrl_tekton_grab",
"attack_mode": "ctrl_attack_mode",
"action_knock_tekton": "ctrl_attack_mode",
}
if ctrl_key_map.has(action_key):
return get_controller_binding_text(ctrl_key_map[action_key])
@@ -315,7 +317,16 @@ func is_controller_button_used(button_index: int) -> String:
return ""
func get_control_keycode(action_name: String) -> int:
return settings.controls.get(action_name, -1)
# Map friendly names to their internal settings.controls keys
var mapped_name = action_name
if action_name == "tekton_grab":
mapped_name = "action_grab_tekton"
elif action_name == "attack_mode":
mapped_name = "action_knock_tekton"
elif action_name == "grab":
mapped_name = "action_grab"
return settings.controls.get(mapped_name, -1)
func get_control_text(action_name: String) -> String:
var code = get_control_keycode(action_name)
+3 -3
View File
@@ -354,9 +354,9 @@ func _run_tutorial() -> void:
if spawned_bot.get("is_knock_mode") == true:
break
# Fallback: if player's attack mode deactivated early despite having timer left, they likely successfully rammed
var t = local_player.get("attack_mode_timer")
if local_player.get("is_attack_mode") == false and t != null and t > 0.0 and t < 3.0:
# Fallback: if player's charged strike deactivated early despite having timer left, they likely successfully rammed
var t = local_player.get("charged_strike_timer")
if local_player.get("is_charged_strike") == false and t != null and t > 0.0 and t < 3.0:
break
await get_tree().process_frame
+10 -2
View File
@@ -123,8 +123,16 @@ func connect_to_nakama_async(email: String = "", password: String = "") -> bool:
return false
elif socket_result.is_exception():
var err = socket_result.get_exception()
printerr("[NakamaManager] Socket Error: %s (Code: %s)" % [err.message, err.status_code])
emit_signal("connection_failed", err.message)
var err_msg = "Socket connection failed"
if err and not err.message.is_empty():
err_msg = err.message
elif err and err.status_code >= 0:
err_msg = "Socket connection failed with code %s" % err.status_code
printerr("[NakamaManager] Socket Error: %s (Code: %s)" % [err_msg, err.status_code if err else -1])
emit_signal("connection_failed", err_msg)
if socket:
socket.close()
socket = null
return false
# 3. Initialize Multiplayer Bridge
+15
View File
@@ -414,6 +414,15 @@ func spawn_tiles_around(count: int = 4):
# FIX 1: Make tekton look/rotate toward a random spawning direction
if not is_carried and not is_thrown:
var random_angle = rng.randf_range(0, TAU)
# If it's a static turret, make it face the target tile it's about to spawn instead
if is_static_turret:
# We don't have a specific target yet, but we can pick an average direction
# Or just let it throw randomly like the others. Wait, the user wants:
# "static tekton, should facing toward where they're going to thrown the tiles"
# We'll calculate rotation inside the spawning loop for static turrets.
pass
else:
rotation.y = random_angle
# Play throw animation
@@ -439,6 +448,12 @@ func spawn_tiles_around(count: int = 4):
var pos = current_position + Vector2i(x, y)
# For static turret, update rotation to face the exact tile being thrown
if is_static_turret and not is_carried and not is_thrown:
var throw_dir = Vector3(x, 0, y).normalized()
if throw_dir.length_squared() > 0.01:
rotation.y = atan2(throw_dir.x, throw_dir.z)
# Don't overwrite the Tekton's own cell? Or do?
# Maybe avoid center.
if x == 0 and y == 0: continue
+8 -8
View File
@@ -39,14 +39,14 @@ func test_admin_parses_user_json_correctly():
var json_str = JSON.stringify(test_user_data)
var parsed = JSON.parse_string(json_str)
assert_is(parsed, Dictionary, "Parsed JSON should be a Dictionary")
assert_true(parsed is Dictionary, "Parsed JSON should be a Dictionary")
assert_eq(parsed.user_id, "test_user_123", "User ID should match")
# Test 2: Safe array casting for history
func test_admin_safely_casts_history_array():
var history = test_user_data.get("history", [])
assert_is(history, Array, "History should be an Array")
assert_true(history is Array, "History should be an Array")
assert_eq(history.size(), 3, "History should have 3 entries")
# Test 3: History entries are dictionaries
@@ -54,7 +54,7 @@ func test_admin_history_entries_are_valid_dicts():
var history = test_user_data.get("history", [])
for entry in history:
assert_is(entry, Dictionary, "Each history entry should be a Dictionary")
assert_true(entry is Dictionary, "Each history entry should be a Dictionary")
assert_has(entry, "action", "History entry should have 'action' field")
assert_has(entry, "timestamp", "History entry should have 'timestamp' field")
@@ -68,10 +68,10 @@ func test_admin_handles_invalid_history_gracefully():
var history = invalid_data.get("history", [])
# Should default to empty array if not an array
if not history is Array:
if not (history is Array):
history = []
assert_is(history, Array, "History should be converted to Array")
assert_true(history is Array, "History should be converted to Array")
# Test 5: History dialog displays correct number of entries
func test_admin_history_dialog_shows_all_entries():
@@ -106,7 +106,7 @@ func test_admin_handles_missing_user_fields():
assert_eq(username, "Unknown", "Should use default for missing username")
assert_eq(level, 0, "Should use default for missing level")
assert_is(history, Array, "Should default to empty array for missing history")
assert_true(history is Array, "Should default to empty array for missing history")
# Test 8: Large history doesn't cause performance issues
func test_admin_handles_large_history():
@@ -128,7 +128,7 @@ func test_admin_history_actions_are_strings():
for entry in history:
var action = entry.get("action", "")
assert_is(action, String, "Action should be a String")
assert_true(action is String, "Action should be a String")
assert_true(action.length() > 0, "Action should not be empty")
# Test 10: Timestamps are valid numbers
@@ -137,7 +137,7 @@ func test_admin_history_timestamps_are_numbers():
for entry in history:
var timestamp = entry.get("timestamp", 0)
assert_is(timestamp, int, "Timestamp should be an integer")
assert_true(timestamp is int, "Timestamp should be an integer")
assert_true(timestamp >= 0, "Timestamp should be non-negative")
func after_all():
+1 -1
View File
@@ -170,7 +170,7 @@ func _store_password(password: String) -> String:
# Should hash, not store plaintext
return password.sha256_text()
func _attempt_auth(user: String, pass: String) -> bool:
func _attempt_auth(user: String, password: String) -> bool:
return false # Should fail with wrong credentials
func _get_logged_content() -> String:
+2 -2
View File
@@ -65,12 +65,12 @@ func test_central_error_handling():
# Test 9: Service ownership is clear
func test_service_ownership_clear():
var owners = _get_service_owners()
assert_is_not_empty(owners, "Service owners should be defined")
assert_false(owners.is_empty(), "Service owners should be defined")
# Test 10: Facade provides unified interface
func test_facade_unified_interface():
var methods = _get_facade_methods()
assert_is_not_empty(methods, "Facade should provide methods")
assert_false(methods.is_empty(), "Facade should provide methods")
# Helper functions
func _facade_has_service(service_name: String) -> bool:
+1 -1
View File
@@ -61,7 +61,7 @@ func test_errors_centrally_handled():
# Test 9: Facade provides unified interface
func test_unified_interface():
var methods = _get_facade_methods()
assert_is_not_empty(methods, "Facade should provide unified interface")
assert_false(methods.is_empty(), "Facade should provide unified interface")
# Test 10: No direct service access
func test_no_direct_service_access():
+2 -2
View File
@@ -20,7 +20,7 @@ func after_each():
# Test 1: Debug gates are properly configured
func test_debug_gates_configured():
var debug_gates = _get_debug_gates()
assert_is_not_empty(debug_gates, "Debug gates should be configured")
assert_false(debug_gates.is_empty(), "Debug gates should be configured")
# Test 2: No hardcoded debug flags in release
func test_no_hardcoded_debug_flags():
@@ -38,7 +38,7 @@ func test_telemetry_can_be_disabled():
# Test 4: Dead code paths are removed
func test_dead_code_paths_removed():
var dead_paths = _find_dead_code_paths()
assert_is_empty(dead_paths, "Should have no dead code paths")
assert_true(dead_paths.is_empty(), "Should have no dead code paths")
# Test 5: Debug output is conditional
func test_debug_output_conditional():
+1 -1
View File
@@ -92,7 +92,7 @@ func test_build_artifact_extensions():
# Test 9: Deployment config is not empty
func test_deployment_config_not_empty():
assert_is_not_empty(deployment_config, "Deployment config should not be empty")
assert_false(deployment_config.is_empty(), "Deployment config should not be empty")
# Test 10: All platforms have unique package names or formats
func test_platform_uniqueness():
+1 -1
View File
@@ -37,7 +37,7 @@ func test_guest_profile_created():
# Test 2: Guest progress is tracked
func test_guest_progress_tracked():
var progress = guest_profile.get("progress", {})
assert_is_not_empty(progress, "Guest progress should be tracked")
assert_false(progress.is_empty(), "Guest progress should be tracked")
# Test 3: Guest can be linked to persistent identity
func test_guest_can_link_to_identity():
+4 -4
View File
@@ -73,17 +73,17 @@ func test_option_values_match_schema():
var expected_type = schema[option]["type"]
if expected_type == "bool":
assert_is(value, bool, "Option '%s' should be bool" % option)
assert_true(value is bool, "Option '%s' should be bool" % option)
# Test 6: Game modes are defined
func test_game_modes_defined():
var modes = mode_config.get("game_modes", [])
assert_is_not_empty(modes, "Game modes should be defined")
assert_false(modes.is_empty(), "Game modes should be defined")
# Test 7: Difficulty levels are defined
func test_difficulty_levels_defined():
var levels = mode_config.get("difficulty_levels", [])
assert_is_not_empty(levels, "Difficulty levels should be defined")
assert_false(levels.is_empty(), "Difficulty levels should be defined")
# Test 8: No inconsistent option values
func test_no_inconsistent_values():
@@ -94,7 +94,7 @@ func test_no_inconsistent_values():
var value = options[option]
var default = schema[option]["default"]
# Value should be same type as default
assert_is(value, typeof(default), "Option '%s' type mismatch" % option)
assert_eq(typeof(value), typeof(default), "Option '%s' type mismatch" % option)
# Test 9: Config can be validated against schema
func test_config_validates_against_schema():
+2 -2
View File
@@ -39,7 +39,7 @@ func test_app_id_configured():
# Test 2: Depots configured for each platform
func test_depots_configured():
var depots = steam_config.get("depots", {})
assert_is_not_empty(depots, "Depots should be configured")
assert_false(depots.is_empty(), "Depots should be configured")
# Test 3: Windows depot exists
func test_windows_depot_exists():
@@ -59,7 +59,7 @@ func test_linux_depot_exists():
# Test 6: Branches defined
func test_branches_defined():
var branches = steam_config.get("branches", {})
assert_is_not_empty(branches, "Branches should be defined")
assert_false(branches.is_empty(), "Branches should be defined")
# Test 7: Main branch exists
func test_main_branch_exists():
+1 -1
View File
@@ -65,7 +65,7 @@ func test_desync_triggers_correction():
var server_pos = Vector3(50, 50, 50)
var correction = _calculate_position_correction(client_pos, server_pos)
assert_is_not_empty(correction, "Desync should trigger correction")
assert_false(correction.is_empty(), "Desync should trigger correction")
# Test 8: Multiple axis deviation is calculated correctly
func test_multi_axis_deviation():