godot-genre-shooter
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseGenre: Shooter (FPS/TPS)
游戏类型:射击游戏(FPS/TPS)
Gunplay feel, responsive combat, and competitive balance define shooters.
枪械手感、流畅的战斗体验以及竞技平衡性是射击游戏的核心要素。
Available Scripts
可用脚本
advanced_weapon_controller.gd
advanced_weapon_controller.gd
Expert pattern for recoil, bloom, and dual hitscan/projectile systems with object pooling notes.
包含后坐力、扩散以及双Hitscan/投射物系统的专家级实现方案,附带对象池相关说明。
Core Loop
核心循环
Engage → Aim → Fire → Kill Confirm → Acquire NextEngage → Aim → Fire → Kill Confirm → Acquire NextNEVER Do in Shooters
射击游戏开发禁忌
- NEVER use for hit detection — Hitscan MUST use physics raycasts in
_process()or on-demand. Frame-rate dependent accuracy breaks competitive integrity._physics_process() - NEVER apply recoil to the weapon model transform — Recoil affects CAMERA rotation (view) and SPREAD (accuracy), not the gun's visual position. Players learn to control camera, not 3D models.
- NEVER use single for gunfire — Layered audio (shot + mechanical + tail) creates punchy feel. Single-stream guns sound flat and amateurish.
AudioStreamPlayer - NEVER sync projectiles with per-bullet — Bandwidth death. Use client-side prediction for visuals, server-authoritative hit validation. Compress: send firing event, not each frame's position.
rpc() - NEVER use overlap for hitscan hits — This is 10-100x slower than
Area3D. Areas are for triggers (health pickups), not instant ballistics.PhysicsRayQueryParameters3D - NEVER hardcode damage values in weapon script — Export stats to for weapon data. Designers need iteration without code changes. Use
Resource.WeaponData.tres - NEVER allow client-authoritative hit decisions in multiplayer — Client says "I shot you" = hacking paradise. Server validates all damage with lag compensation (rewinding).
- 绝对不要使用进行命中检测 — Hitscan必须在
_process()中或按需使用物理射线检测。依赖帧率的判定精度会破坏竞技公平性。_physics_process() - 绝对不要将后坐力应用到武器模型的变换组件上 — 后坐力影响的是相机旋转(视角)和扩散(精度),而非枪械的视觉位置。玩家需要适应的是相机控制,而非3D模型的移动。
- 绝对不要使用单个播放枪声 — 分层音频(主枪声+机械声+尾音)能打造更有冲击力的音效。单轨枪声听起来平淡且业余。
AudioStreamPlayer - 绝对不要通过同步每一发投射物 — 这会导致带宽耗尽。使用客户端预测处理视觉效果,由服务器进行权威命中验证。优化方案:仅发送开火事件,而非每帧的位置数据。
rpc() - 绝对不要使用的重叠检测实现Hitscan命中 — 这比
Area3D慢10-100倍。Area组件适用于触发类功能(如拾取生命值),而非即时弹道检测。PhysicsRayQueryParameters3D - 绝对不要在武器脚本中硬编码伤害值 — 将属性导出到中存储武器数据。这样设计师无需修改代码即可调整数值。使用
Resource文件。WeaponData.tres - 绝对不要在多人游戏中让客户端拥有命中判定权限 — 若由客户端判定“我击中了你”,会滋生大量外挂。服务器需通过延迟补偿(回滚机制)验证所有伤害。
Weapon System Architecture
武器系统架构
gdscript
class_name Weapon
extends Node3D
@export_group("Stats")
@export var damage: int = 20
@export var fire_rate: float = 0.1 # Seconds between shots
@export var magazine_size: int = 30
@export var reload_time: float = 2.0
@export var range: float = 100.0
@export_group("Recoil")
@export var base_recoil: Vector2 = Vector2(0.5, 2.0) # X, Y degrees
@export var recoil_recovery_speed: float = 5.0
@export var max_spread: float = 5.0
@export_group("Type")
@export var is_hitscan: bool = true
@export var projectile_scene: PackedScene
var current_ammo: int
var can_fire: bool = true
var current_recoil: Vector2 = Vector2.ZERO
var current_spread: float = 0.0
signal fired
signal reloaded
signal ammo_changed(current: int, max: int)gdscript
class_name Weapon
extends Node3D
@export_group("Stats")
@export var damage: int = 20
@export var fire_rate: float = 0.1 # Seconds between shots
@export var magazine_size: int = 30
@export var reload_time: float = 2.0
@export var range: float = 100.0
@export_group("Recoil")
@export var base_recoil: Vector2 = Vector2(0.5, 2.0) # X, Y degrees
@export var recoil_recovery_speed: float = 5.0
@export var max_spread: float = 5.0
@export_group("Type")
@export var is_hitscan: bool = true
@export var projectile_scene: PackedScene
var current_ammo: int
var can_fire: bool = true
var current_recoil: Vector2 = Vector2.ZERO
var current_spread: float = 0.0
signal fired
signal reloaded
signal ammo_changed(current: int, max: int)Hitscan vs Projectile
Hitscan vs 投射物
Hitscan (Instant Hit)
Hitscan(即时命中)
gdscript
func fire_hitscan() -> void:
if not can_fire or current_ammo <= 0:
return
current_ammo -= 1
ammo_changed.emit(current_ammo, magazine_size)
var camera := get_viewport().get_camera_3d()
var ray_origin := camera.global_position
var ray_direction := -camera.global_basis.z
# Apply spread
ray_direction = apply_spread(ray_direction)
var space := get_world_3d().direct_space_state
var query := PhysicsRayQueryParameters3D.create(
ray_origin,
ray_origin + ray_direction * range
)
query.collision_mask = collision_mask
var result := space.intersect_ray(query)
if result:
var hit_point: Vector3 = result.position
var hit_normal: Vector3 = result.normal
var hit_object: Object = result.collider
spawn_impact_effect(hit_point, hit_normal)
if hit_object.has_method("take_damage"):
var hit_zone := determine_hit_zone(result)
var final_damage := calculate_damage(damage, hit_zone)
hit_object.take_damage(final_damage, hit_zone)
apply_recoil()
start_fire_cooldown()
fired.emit()
func determine_hit_zone(result: Dictionary) -> String:
# Use collision shape name or bone detection for hitboxes
if "headshot" in result.collider.name.to_lower():
return "head"
elif "chest" in result.collider.name.to_lower():
return "chest"
return "body"
func calculate_damage(base: int, zone: String) -> int:
match zone:
"head": return int(base * 2.5)
"chest": return int(base * 1.0)
_: return int(base * 0.8)gdscript
func fire_hitscan() -> void:
if not can_fire or current_ammo <= 0:
return
current_ammo -= 1
ammo_changed.emit(current_ammo, magazine_size)
var camera := get_viewport().get_camera_3d()
var ray_origin := camera.global_position
var ray_direction := -camera.global_basis.z
# Apply spread
ray_direction = apply_spread(ray_direction)
var space := get_world_3d().direct_space_state
var query := PhysicsRayQueryParameters3D.create(
ray_origin,
ray_origin + ray_direction * range
)
query.collision_mask = collision_mask
var result := space.intersect_ray(query)
if result:
var hit_point: Vector3 = result.position
var hit_normal: Vector3 = result.normal
var hit_object: Object = result.collider
spawn_impact_effect(hit_point, hit_normal)
if hit_object.has_method("take_damage"):
var hit_zone := determine_hit_zone(result)
var final_damage := calculate_damage(damage, hit_zone)
hit_object.take_damage(final_damage, hit_zone)
apply_recoil()
start_fire_cooldown()
fired.emit()
func determine_hit_zone(result: Dictionary) -> String:
# Use collision shape name or bone detection for hitboxes
if "headshot" in result.collider.name.to_lower():
return "head"
elif "chest" in result.collider.name.to_lower():
return "chest"
return "body"
func calculate_damage(base: int, zone: String) -> int:
match zone:
"head": return int(base * 2.5)
"chest": return int(base * 1.0)
_: return int(base * 0.8)Projectile (Physical Bullet)
投射物(实体子弹)
gdscript
class_name Projectile
extends CharacterBody3D
@export var speed := 100.0
@export var damage := 20
@export var gravity_affected := true
@export var lifetime := 5.0
var direction: Vector3
var shooter: Node3D
func _ready() -> void:
await get_tree().create_timer(lifetime).timeout
queue_free()
func _physics_process(delta: float) -> void:
if gravity_affected:
velocity.y -= 9.8 * delta
velocity = direction * speed
var collision := move_and_collide(velocity * delta)
if collision:
var collider := collision.get_collider()
if collider != shooter and collider.has_method("take_damage"):
collider.take_damage(damage)
spawn_impact(collision.get_position(), collision.get_normal())
queue_free()gdscript
class_name Projectile
extends CharacterBody3D
@export var speed := 100.0
@export var damage := 20
@export var gravity_affected := true
@export var lifetime := 5.0
var direction: Vector3
var shooter: Node3D
func _ready() -> void:
await get_tree().create_timer(lifetime).timeout
queue_free()
func _physics_process(delta: float) -> void:
if gravity_affected:
velocity.y -= 9.8 * delta
velocity = direction * speed
var collision := move_and_collide(velocity * delta)
if collision:
var collider := collision.get_collider()
if collider != shooter and collider.has_method("take_damage"):
collider.take_damage(damage)
spawn_impact(collision.get_position(), collision.get_normal())
queue_free()Recoil System
后坐力系统
Three types of recoil working together:
gdscript
class_name RecoilSystem
extends Node
var visual_recoil: Vector2 = Vector2.ZERO # Camera kick
var pattern_offset: Vector2 = Vector2.ZERO # Deterministic pattern
var spread_bloom: float = 0.0 # Accuracy loss
@export var recoil_pattern: Array[Vector2] # Predefined spray pattern
var pattern_index: int = 0
func apply_recoil(weapon: Weapon) -> void:
# 1. Visual recoil - camera kick
visual_recoil.y += weapon.base_recoil.y * randf_range(0.8, 1.2)
visual_recoil.x += weapon.base_recoil.x * randf_range(-1.0, 1.0)
# 2. Pattern recoil - learnable spray
if pattern_index < recoil_pattern.size():
pattern_offset += recoil_pattern[pattern_index]
pattern_index += 1
# 3. Spread bloom - reduced accuracy
spread_bloom = min(spread_bloom + 0.5, weapon.max_spread)
func recover_recoil(delta: float, recovery_speed: float) -> void:
visual_recoil = visual_recoil.lerp(Vector2.ZERO, recovery_speed * delta)
pattern_offset = pattern_offset.lerp(Vector2.ZERO, recovery_speed * delta)
spread_bloom = lerp(spread_bloom, 0.0, recovery_speed * delta)
if visual_recoil.length() < 0.01:
pattern_index = 0 # Reset pattern
func get_spread_direction(base_direction: Vector3) -> Vector3:
var spread_angle := deg_to_rad(spread_bloom)
var random_offset := Vector2(
randf_range(-spread_angle, spread_angle),
randf_range(-spread_angle, spread_angle)
)
return base_direction.rotated(Vector3.UP, random_offset.x).rotated(Vector3.RIGHT, random_offset.y)三种后坐力机制协同工作:
gdscript
class_name RecoilSystem
extends Node
var visual_recoil: Vector2 = Vector2.ZERO # Camera kick
var pattern_offset: Vector2 = Vector2.ZERO # Deterministic pattern
var spread_bloom: float = 0.0 # Accuracy loss
@export var recoil_pattern: Array[Vector2] # Predefined spray pattern
var pattern_index: int = 0
func apply_recoil(weapon: Weapon) -> void:
# 1. Visual recoil - camera kick
visual_recoil.y += weapon.base_recoil.y * randf_range(0.8, 1.2)
visual_recoil.x += weapon.base_recoil.x * randf_range(-1.0, 1.0)
# 2. Pattern recoil - learnable spray
if pattern_index < recoil_pattern.size():
pattern_offset += recoil_pattern[pattern_index]
pattern_index += 1
# 3. Spread bloom - reduced accuracy
spread_bloom = min(spread_bloom + 0.5, weapon.max_spread)
func recover_recoil(delta: float, recovery_speed: float) -> void:
visual_recoil = visual_recoil.lerp(Vector2.ZERO, recovery_speed * delta)
pattern_offset = pattern_offset.lerp(Vector2.ZERO, recovery_speed * delta)
spread_bloom = lerp(spread_bloom, 0.0, recovery_speed * delta)
if visual_recoil.length() < 0.01:
pattern_index = 0 # Reset pattern
func get_spread_direction(base_direction: Vector3) -> Vector3:
var spread_angle := deg_to_rad(spread_bloom)
var random_offset := Vector2(
randf_range(-spread_angle, spread_angle),
randf_range(-spread_angle, spread_angle)
)
return base_direction.rotated(Vector3.UP, random_offset.x).rotated(Vector3.RIGHT, random_offset.y)Aim Assist (Controller Support)
瞄准辅助(手柄支持)
gdscript
class_name AimAssist
extends Node3D
@export var assist_range := 50.0
@export var assist_angle := 15.0 # Degrees
@export var friction_strength := 0.3 # Slowdown near targets
@export var magnetism_strength := 0.1 # Pull toward targets
func apply_aim_assist(look_input: Vector2, camera: Camera3D) -> Vector2:
var target := find_closest_target(camera)
if not target:
return look_input
var to_target := target.global_position - camera.global_position
var camera_forward := -camera.global_basis.z
var angle := rad_to_deg(camera_forward.angle_to(to_target.normalized()))
if angle > assist_angle:
return look_input
# Friction - slow movement near targets
var friction := 1.0 - (friction_strength * (1.0 - angle / assist_angle))
look_input *= friction
# Magnetism - subtle pull toward target
var target_screen_pos := camera.unproject_position(target.global_position)
var screen_center := get_viewport().get_visible_rect().size / 2
var pull_direction := (target_screen_pos - screen_center).normalized()
look_input += pull_direction * magnetism_strength * (1.0 - angle / assist_angle)
return look_input
func find_closest_target(camera: Camera3D) -> Node3D:
var closest: Node3D = null
var closest_angle := assist_angle
for target in get_tree().get_nodes_in_group("enemies"):
var to_target := target.global_position - camera.global_position
var angle := rad_to_deg((-camera.global_basis.z).angle_to(to_target.normalized()))
if angle < closest_angle and to_target.length() < assist_range:
if has_line_of_sight(camera.global_position, target.global_position):
closest = target
closest_angle = angle
return closestgdscript
class_name AimAssist
extends Node3D
@export var assist_range := 50.0
@export var assist_angle := 15.0 # Degrees
@export var friction_strength := 0.3 # Slowdown near targets
@export var magnetism_strength := 0.1 # Pull toward targets
func apply_aim_assist(look_input: Vector2, camera: Camera3D) -> Vector2:
var target := find_closest_target(camera)
if not target:
return look_input
var to_target := target.global_position - camera.global_position
var camera_forward := -camera.global_basis.z
var angle := rad_to_deg(camera_forward.angle_to(to_target.normalized()))
if angle > assist_angle:
return look_input
# Friction - slow movement near targets
var friction := 1.0 - (friction_strength * (1.0 - angle / assist_angle))
look_input *= friction
# Magnetism - subtle pull toward target
var target_screen_pos := camera.unproject_position(target.global_position)
var screen_center := get_viewport().get_visible_rect().size / 2
var pull_direction := (target_screen_pos - screen_center).normalized()
look_input += pull_direction * magnetism_strength * (1.0 - angle / assist_angle)
return look_input
func find_closest_target(camera: Camera3D) -> Node3D:
var closest: Node3D = null
var closest_angle := assist_angle
for target in get_tree().get_nodes_in_group("enemies"):
var to_target := target.global_position - camera.global_position
var angle := rad_to_deg((-camera.global_basis.z).angle_to(to_target.normalized()))
if angle < closest_angle and to_target.length() < assist_range:
if has_line_of_sight(camera.global_position, target.global_position):
closest = target
closest_angle = angle
return closestWeapon Feel Polish
枪械手感优化
Camera Effects
相机特效
gdscript
func on_weapon_fired() -> void:
# Screen shake
camera_shake(0.1, 0.05)
# FOV punch
camera.fov += 2.0
await get_tree().create_timer(0.05).timeout
camera.fov -= 2.0
# Muzzle flash
muzzle_flash.visible = true
await get_tree().create_timer(0.02).timeout
muzzle_flash.visible = false
func on_weapon_reloaded() -> void:
# Lock controls during reload
can_fire = false
can_aim = false
play_animation("reload")
await get_tree().create_timer(reload_time).timeout
current_ammo = magazine_size
can_fire = true
can_aim = truegdscript
func on_weapon_fired() -> void:
# Screen shake
camera_shake(0.1, 0.05)
# FOV punch
camera.fov += 2.0
await get_tree().create_timer(0.05).timeout
camera.fov -= 2.0
# Muzzle flash
muzzle_flash.visible = true
await get_tree().create_timer(0.02).timeout
muzzle_flash.visible = false
func on_weapon_reloaded() -> void:
# Lock controls during reload
can_fire = false
can_aim = false
play_animation("reload")
await get_tree().create_timer(reload_time).timeout
current_ammo = magazine_size
can_fire = true
can_aim = trueAudio Layering
音频分层
gdscript
@export var fire_sounds: Array[AudioStream] # Random selection
@export var tail_sound: AudioStream # Reverb/echo
@export var mechanical_sound: AudioStream # Gun mechanism
func play_fire_audio() -> void:
# Main shot
var shot := fire_sounds.pick_random()
fire_audio_player.stream = shot
fire_audio_player.play()
# Mechanical click
mechanical_player.play()
# Tail (delayed reverb)
await get_tree().create_timer(0.1).timeout
tail_player.play()gdscript
@export var fire_sounds: Array[AudioStream] # Random selection
@export var tail_sound: AudioStream # Reverb/echo
@export var mechanical_sound: AudioStream # Gun mechanism
func play_fire_audio() -> void:
# Main shot
var shot := fire_sounds.pick_random()
fire_audio_player.stream = shot
fire_audio_player.play()
# Mechanical click
mechanical_player.play()
# Tail (delayed reverb)
await get_tree().create_timer(0.1).timeout
tail_player.play()Weapon Selection Decision Tree
武器选型决策树
When designing weapon balance:
- High fire rate (SMG) = Low damage per shot, rewards tracking aim
- Low fire rate (Sniper) = High damage, rewards precision
- Shotguns = Spread pattern (5-8 pellets), effective range <10m
- ARs = Jack-of-all-trades, medium everything
Technical implementation:
- Pistol/AR: Hitscan (instant feedback)
- Rocket/Grenade: Projectile with gravity
- S niper: Hitscan with tracer visual
设计武器平衡时:
- 高射速(如SMG)= 单发伤害低,奖励持续瞄准追踪
- 低射速(如狙击枪)= 单发伤害高,奖励精准瞄准
- 霰弹枪 = 扩散模式(5-8发弹丸),有效射程<10米
- 突击步枪 = 全能型,各项属性中等
技术实现方案:
- 手枪/突击步枪:使用Hitscan(即时反馈)
- 火箭筒/手雷:使用带重力的投射物
- 狙击枪:使用Hitscan并添加弹道轨迹视觉效果
Multiplayer Client Prediction Pattern
多人游戏客户端预测模式
gdscript
undefinedgdscript
undefinedCLIENT: Instant feedback, no waiting for server
CLIENT: Instant feedback, no waiting for server
func fire_client() -> void:
play_effects_immediate() # Muzzle flash, recoil, audio
local_hitscan_visual() # Visual blood splatter only
rpc_id(1, "server_validate_shot", camera.global_transform)
func fire_client() -> void:
play_effects_immediate() # Muzzle flash, recoil, audio
local_hitscan_visual() # Visual blood splatter only
rpc_id(1, "server_validate_shot", camera.global_transform)
SERVER: Authoritative damage
SERVER: Authoritative damage
@rpc("any_peer")
func server_validate_shot(shooter_transform: Transform3D) -> void:
var hit = perform_server_hitscan(shooter_transform)
if hit and is_valid_shot(hit):
rpc("confirm_hit", hit.victim_id, hit.damage)
@rpc("any_peer")
func server_validate_shot(shooter_transform: Transform3D) -> void:
var hit = perform_server_hitscan(shooter_transform)
if hit and is_valid_shot(hit):
rpc("confirm_hit", hit.victim_id, hit.damage)
EDGE CASE: What if client's visual hit doesn't match server?
EDGE CASE: What if client's visual hit doesn't match server?
SOLUTION: Server wins. Client shows "no reg" indicator if mismatch.
SOLUTION: Server wins. Client shows "no reg" indicator if mismatch.
undefinedundefinedCommon Pitfalls & Expert Fixes
常见陷阱与专家解决方案
- Weak bullet impact → Triple-layer audio (shot+tail+mechanical) + screen shake + blood VFX + damage number
- Guns feel identical → Unique recoil patterns (SMG: tight vertical, AK: strong horizontal kick)
- No skill ceiling → Learnable spray patterns (CS:GO style), not pure RNG spread
- Controller aim frustration → Friction (0.3 slowdown near targets) + subtle 0.1 magnetism
- 子弹冲击力弱 → 使用三层音频(主枪声+尾音+机械声)+ 屏幕震动 + 血液特效 + 伤害数值显示
- 所有枪械手感雷同 → 设计独特的后坐力模式(如SMG:紧凑垂直后坐,AK:强烈水平后坐)
- 缺乏技术上限 → 使用可学习的扫射模式(类似CS:GO),而非纯随机扩散
- 手柄瞄准体验差 → 添加摩擦力(目标附近减速0.3)+ 轻微磁吸(0.1强度)
Godot-Specific Tips
Godot专属技巧
- Raycasts: Use with proper layer masks
PhysicsRayQueryParameters3D - Projectiles: or
CharacterBody3Ddepending on physics needsRigidBody3D - Audio: Multiple for layered gun sounds
AudioStreamPlayer3D - Animations: for weapon state machines (idle, aim, fire, reload)
AnimationTree
- 射线检测:使用并设置正确的层掩码
PhysicsRayQueryParameters3D - 投射物:根据物理需求选择或
CharacterBody3DRigidBody3D - 音频:使用多个实现分层枪声
AudioStreamPlayer3D - 动画:使用实现武器状态机( idle、瞄准、开火、换弹)
AnimationTree
Reference
参考资料
- Master Skill: godot-master
- 大师技能:godot-master