godot-genre-sandbox
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseGenre: Sandbox
游戏类型:沙盒
Physical simulation, emergent play, and player creativity define this genre.
物理模拟、涌现式玩法和玩家创意是这类游戏的核心特征。
Available Scripts
可用脚本
voxel_chunk_manager.gd
voxel_chunk_manager.gd
Expert chunked rendering using for thousands of voxels. Includes greedy meshing pattern and performance notes.
MultiMeshInstance3D基于实现的专家级区块化渲染方案,可支持数千个体素。包含贪心网格合并模式和性能优化说明。
MultiMeshInstance3DCore Loop
核心循环
- Explore: Player discovers world rules and materials
- Experiment: Player tests interactions (fire burns wood)
- Build: Player constructs structures or machines
- Simulate: Game runs physics/logic systems
- Share: Player saves/shares creation
- Emergence: Unintended complex behaviors from simple rules
- 探索:玩家探索世界规则和各类材质
- 实验:玩家测试元素交互(比如火焰燃烧木材)
- 建造:玩家搭建建筑或机械装置
- 模拟:游戏运行物理/逻辑系统
- 分享:玩家保存或分享自己的创作
- 涌现:简单规则衍生出的复杂意外行为
NEVER Do in Sandbox Games
沙盒开发绝对禁忌
- NEVER simulate the entire world every frame — Only update "dirty" chunks with recent changes. Sleeping chunks waste 90%+ of CPU. Use spatial hashing to track active regions.
- NEVER use individual nodes for voxels — 1000+ physics bodies = instant crash. Use cellular automata for fluids/sand, static collision for solid blocks, and only dynamic bodies for player-placed objects.
RigidBody - NEVER save absolute transforms for every block — A 256×256 world = 65,536 blocks. Use chunk-based RLE (Run-Length Encoding): compresses massive empty spaces.
{type:AIR, count:50000} - NEVER update instance transforms every frame — This forces GPU buffer updates. Batch changes, rebuild chunks when changed, not every tick.
MultiMesh - NEVER hardcode element interactions () — Use property-based systems:
if wood + fire: burn(). This enables emergent combinations players discover.if temperature > ignition_point and flammable > 0 - NEVER use for every grid cell — Nodes have 200+ bytes overhead. A million-block world would need 200MB+ just for node metadata. Use typed
NodeorDictionaryindexed byPackedInt32Array.position.x + position.y * width - NEVER raycast against all voxels for tool placement — Use grid quantization: to directly calculate target cell. Raycasts are O(n) with voxel count.
floor(mouse_pos / block_size)
- 绝对不要每帧模拟整个世界 — 仅更新有近期变更的“脏”区块。处于休眠状态的区块会浪费90%以上的CPU资源。使用空间哈希来追踪活跃区域。
- 绝对不要为每个体素单独使用节点 — 1000+个物理体会直接导致崩溃。流体/沙子使用元胞自动机,固体方块使用静态碰撞,仅玩家放置的物体使用动态物理体。
RigidBody - 绝对不要保存每个方块的绝对变换信息 — 一个256×256的世界包含65536个方块。使用基于区块的行程编码(RLE):可大幅压缩大面积空白区域。
{type:AIR, count:50000} - 绝对不要每帧更新实例的变换信息 — 这会强制GPU更新缓冲区。批量处理变更,仅在区块修改时重建,而非每帧更新。
MultiMesh - 绝对不要硬编码元素交互逻辑(如)— 使用基于属性的系统:
if wood + fire: burn()。这样能让玩家发现更多涌现式的元素组合。if temperature > ignition_point and flammable > 0 - 绝对不要为每个网格单元使用节点 — 每个节点有200多字节的开销。百万方块的世界仅节点元数据就需要200MB以上的内存。使用类型化
Node或Dictionary,通过PackedInt32Array进行索引。position.x + position.y * width - 绝对不要为工具放置操作对所有体素进行射线检测 — 使用网格量化:直接计算目标单元格。射线检测的时间复杂度是O(n),与体素数量正相关。
floor(mouse_pos / block_size)
Architecture Patterns
架构模式
1. Element System (Property-Based Emergence)
1. 元素系统(基于属性的涌现式设计)
Model material properties, not behaviors. Interactions emerge from overlapping properties.
gdscript
undefined为材质建模属性而非行为。交互效果由重叠的属性自然涌现。
gdscript
undefinedelement_data.gd
element_data.gd
class_name ElementData extends Resource
enum Type { SOLID, LIQUID, GAS, POWDER }
@export var id: String = "air"
@export var type: Type = Type.GAS
@export var density: float = 0.0 # For liquid flow direction
@export var flammable: float = 0.0 # 0-1: Chance to ignite
@export var ignition_temp: float = 400.0
@export var conductivity: float = 0.0 # For electricity/heat
@export var hardness: float = 1.0 # Mining time multiplier
class_name ElementData extends Resource
enum Type { SOLID, LIQUID, GAS, POWDER }
@export var id: String = "air"
@export var type: Type = Type.GAS
@export var density: float = 0.0 # For liquid flow direction
@export var flammable: float = 0.0 # 0-1: Chance to ignite
@export var ignition_temp: float = 400.0
@export var conductivity: float = 0.0 # For electricity/heat
@export var hardness: float = 1.0 # Mining time multiplier
EDGE CASE: What if two elements have same density but different types?
EDGE CASE: What if two elements have same density but different types?
SOLUTION: Use secondary sort (type enum priority: SOLID > LIQUID > POWDER > GAS)
SOLUTION: Use secondary sort (type enum priority: SOLID > LIQUID > POWDER > GAS)
func should_swap_with(other: ElementData) -> bool:
if density == other.density:
return type > other.type # Enum comparison: SOLID(0) > GAS(3)
return density > other.density
undefinedfunc should_swap_with(other: ElementData) -> bool:
if density == other.density:
return type > other.type # Enum comparison: SOLID(0) > GAS(3)
return density > other.density
undefined2. Cellular Automata Grid (Falling Sand Simulation)
2. 元胞自动机网格(落沙模拟)
Update order matters. Top-down prevents "teleporting" godot-particles.
gdscript
undefined更新顺序至关重要。从上到下更新可避免Godot粒子“瞬移”问题。
gdscript
undefinedworld_grid.gd
world_grid.gd
var grid: Dictionary = {} # Vector2i -> ElementData
var dirty_cells: Array[Vector2i] = []
func _physics_process(_delta: float) -> void:
# CRITICAL: Sort top-to-bottom to prevent double-moves
dirty_cells.sort_custom(func(a, b): return a.y < b.y)
for pos in dirty_cells:
simulate_cell(pos)
dirty_cells.clear()func simulate_cell(pos: Vector2i) -> void:
var cell = grid.get(pos)
if not cell: return
match cell.type:
ElementData.Type.LIQUID, ElementData.Type.POWDER:
# Try down, then down-left, then down-right
var targets = [pos + Vector2i.DOWN,
pos + Vector2i(- 1, 1),
pos + Vector2i(1, 1)]
for target in targets:
var neighbor = grid.get(target)
if neighbor and cell.should_swap_with(neighbor):
swap_cells(pos, target)
mark_dirty(target)
return
ElementData.Type.GAS:
# Gases rise (inverse of liquids)
var targets = [pos + Vector2i.UP,
pos + Vector2i(-1, -1),
pos + Vector2i(1, -1)]
# Same swap logic...var grid: Dictionary = {} # Vector2i -> ElementData
var dirty_cells: Array[Vector2i] = []
func _physics_process(_delta: float) -> void:
# CRITICAL: Sort top-to-bottom to prevent double-moves
dirty_cells.sort_custom(func(a, b): return a.y < b.y)
for pos in dirty_cells:
simulate_cell(pos)
dirty_cells.clear()func simulate_cell(pos: Vector2i) -> void:
var cell = grid.get(pos)
if not cell: return
match cell.type:
ElementData.Type.LIQUID, ElementData.Type.POWDER:
# Try down, then down-left, then down-right
var targets = [pos + Vector2i.DOWN,
pos + Vector2i(- 1, 1),
pos + Vector2i(1, 1)]
for target in targets:
var neighbor = grid.get(target)
if neighbor and cell.should_swap_with(neighbor):
swap_cells(pos, target)
mark_dirty(target)
return
ElementData.Type.GAS:
# Gases rise (inverse of liquids)
var targets = [pos + Vector2i.UP,
pos + Vector2i(-1, -1),
pos + Vector2i(1, -1)]
# Same swap logic...EDGE CASE: What if multiple godot-particles want to move into same cell?
EDGE CASE: What if multiple godot-particles want to move into same cell?
SOLUTION: Only mark target dirty, don't double-swap. Next frame resolves conflicts.
SOLUTION: Only mark target dirty, don't double-swap. Next frame resolves conflicts.
undefinedundefined3. Tool System (Strategy Pattern)
3. 工具系统(策略模式)
Decouple input from world modification.
gdscript
undefined将输入逻辑与世界修改逻辑解耦。
gdscript
undefinedtool_base.gd
tool_base.gd
class_name Tool extends Resource
func use(world_pos: Vector2, world: WorldGrid) -> void: pass
class_name Tool extends Resource
func use(world_pos: Vector2, world: WorldGrid) -> void: pass
tool_brush.gd
tool_brush.gd
extends Tool
@export var element: ElementData
@export var radius: int = 1
func use(world_pos: Vector2, world: WorldGrid) -> void:
var grid_pos = Vector2i(floor(world_pos.x), floor(world_pos.y))
# Circle brush pattern
for x in range(-radius, radius + 1):
for y in range(-radius, radius + 1):
if x*x + y*y <= radius*radius: # Circle boundary
var target = grid_pos + Vector2i(x, y)
world.set_cell(target, element)extends Tool
@export var element: ElementData
@export var radius: int = 1
func use(world_pos: Vector2, world: WorldGrid) -> void:
var grid_pos = Vector2i(floor(world_pos.x), floor(world_pos.y))
# Circle brush pattern
for x in range(-radius, radius + 1):
for y in range(-radius, radius + 1):
if x*x + y*y <= radius*radius: # Circle boundary
var target = grid_pos + Vector2i(x, y)
world.set_cell(target, element)FALLBACK: If element placement fails (e.g., occupied by indestructible block)?
FALLBACK: If element placement fails (e.g., occupied by indestructible block)?
Check world.can_place(target) before set_cell(), show visual feedback.
Check world.can_place(target) before set_cell(), show visual feedback.
undefinedundefined4. Chunk-Based Rendering (3D Voxels)
4. 基于区块的渲染(3D体素)
Only render visible faces. Use greedy meshing to merge adjacent blocks.
gdscript
undefined仅渲染可见面。使用贪心网格合并相邻方块。
gdscript
undefinedSee scripts/voxel_chunk_manager.gd for full implementation
See scripts/voxel_chunk_manager.gd for full implementation
EXPERT DECISION TREE:
EXPERT DECISION TREE:
- Small worlds (<100k blocks): Single MeshInstance with SurfaceTool
- Small worlds (<100k blocks): Single MeshInstance with SurfaceTool
- Medium worlds (100k-1M blocks): Chunked MultiMesh (see script)
- Medium worlds (100k-1M blocks): Chunked MultiMesh (see script)
- Large worlds (>1M blocks): Chunked + greedy meshing + LOD
- Large worlds (>1M blocks): Chunked + greedy meshing + LOD
undefinedundefinedSave System for Sandbox Worlds
沙盒世界的存档系统
gdscript
undefinedgdscript
undefinedchunk_save_data.gd
chunk_save_data.gd
class_name ChunkSaveData extends Resource
@export var chunk_coord: Vector2i
@export var rle_data: PackedInt32Array # [type_id, count, type_id, count...]
class_name ChunkSaveData extends Resource
@export var chunk_coord: Vector2i
@export var rle_data: PackedInt32Array # [type_id, count, type_id, count...]
EXPERT TECHNIQUE: Run-Length Encoding
EXPERT TECHNIQUE: Run-Length Encoding
static func encode_chunk(grid: Dictionary, chunk_pos: Vector2i, chunk_size: int) -> ChunkSaveData:
var data = ChunkSaveData.new()
data.chunk_coord = chunk_pos
var run_type: int = -1
var run_count: int = 0
for y in range(chunk_size):
for x in range(chunk_size):
var world_pos = chunk_pos * chunk_size + Vector2i(x, y)
var cell = grid.get(world_pos)
var type_id = cell.id if cell else 0 # 0 = air
if type_id == run_type:
run_count += 1
else:
if run_count > 0:
data.rle_data.append(run_type)
data.rle_data.append(run_count)
run_type = type_id
run_count = 1
# Flush final run
if run_count > 0:
data.rle_data.append(run_type)
data.rle_data.append(run_count)
return datastatic func encode_chunk(grid: Dictionary, chunk_pos: Vector2i, chunk_size: int) -> ChunkSaveData:
var data = ChunkSaveData.new()
data.chunk_coord = chunk_pos
var run_type: int = -1
var run_count: int = 0
for y in range(chunk_size):
for x in range(chunk_size):
var world_pos = chunk_pos * chunk_size + Vector2i(x, y)
var cell = grid.get(world_pos)
var type_id = cell.id if cell else 0 # 0 = air
if type_id == run_type:
run_count += 1
else:
if run_count > 0:
data.rle_data.append(run_type)
data.rle_data.append(run_count)
run_type = type_id
run_count = 1
# Flush final run
if run_count > 0:
data.rle_data.append(run_type)
data.rle_data.append(run_count)
return dataCOMPRESSION RESULT: Empty chunk (16×16 = 256 blocks of air)
COMPRESSION RESULT: Empty chunk (16×16 = 256 blocks of air)
Without RLE: 256 integers = 1024 bytes
Without RLE: 256 integers = 1024 bytes
With RLE: [0, 256] = 8 bytes (128x compression!)
With RLE: [0, 256] = 8 bytes (128x compression!)
undefinedundefinedPhysics Joints for Player Creations
玩家创作的物理关节
gdscript
undefinedgdscript
undefinedjoint_tool.gd
joint_tool.gd
func create_hinge(body_a: RigidBody2D, body_b: RigidBody2D, anchor: Vector2) -> void:
var joint = PinJoint2D.new()
joint.global_position = anchor
joint.node_a = body_a.get_path()
joint.node_b = body_b.get_path()
joint.softness = 0.5 # Allows slight flex
add_child(joint)
# EDGE CASE: What if bodies are deleted while joint exists?
# Joint will auto-break in Godot 4.x, but orphaned Node leaks memory.func create_hinge(body_a: RigidBody2D, body_b: RigidBody2D, anchor: Vector2) -> void:
var joint = PinJoint2D.new()
joint.global_position = anchor
joint.node_a = body_a.get_path()
joint.node_b = body_b.get_path()
joint.softness = 0.5 # Allows slight flex
add_child(joint)
# EDGE CASE: What if bodies are deleted while joint exists?
# Joint will auto-break in Godot 4.x, but orphaned Node leaks memory.SOLUTION:
SOLUTION:
body_a.tree_exiting.connect(func(): joint.queue_free())
body_b.tree_exiting.connect(func(): joint.queue_free())
body_a.tree_exiting.connect(func(): joint.queue_free())
body_b.tree_exiting.connect(func(): joint.queue_free())
FALLBACK: Player attaches joint to static geometry?
FALLBACK: Player attaches joint to static geometry?
Check body.freeze == false
before creating joint.
body.freeze == falseCheck body.freeze == false
before creating joint.
body.freeze == falseundefinedundefinedGodot-Specific Expert Notes
Godot专属专家提示
- : MUST be set before buffer allocation. Cannot dynamically grow — requires recreation.
MultiMeshInstance3D.multimesh.instance_count - : Bodies auto-sleep after 2 seconds of no movement. Use
RigidBody2D.sleepingto force wake without adding force.apply_central_impulse(Vector2.ZERO) - vs
GridMap: GridMap uses MeshLibrary (great for variety), MultiMesh uses single mesh (great for speed). Combine: GridMap for structures, MultiMesh for terrain.MultiMesh - Continuous CD: requires convex collision shapes. Use
continuous_cdfor projectiles, NOTCapsuleShape2D.RectangleShape2D
- : 必须在缓冲区分配前设置。无法动态扩容 — 需要重新创建实例。
MultiMeshInstance3D.multimesh.instance_count - : 物体在静止2秒后会自动进入休眠状态。使用
RigidBody2D.sleeping可强制唤醒物体而不施加力。apply_central_impulse(Vector2.ZERO) - vs
GridMap: GridMap使用MeshLibrary(适合多样的模型),MultiMesh使用单一网格(适合高性能场景)。可结合使用:GridMap用于建筑结构,MultiMesh用于地形。MultiMesh - 连续碰撞检测(Continuous CD): 需要凸碰撞形状。对抛射物使用
continuous_cd,而非CapsuleShape2D。RectangleShape2D
Reference
参考
- Master Skill: godot-master
- 核心技能:godot-master