ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Godot 2D俯视角射击生存游戏原型:从零搭建完整项目

Godot 2D俯视角射击生存游戏原型:从零搭建完整项目 这个系列写到第15篇前面一直在拆 Godot 2D 的基础节点、GDScript 语法和场景组织方式东西学了不少但真到动手的时候很多人还是会卡在“这些零件怎么拼成一个能玩的游戏”这一步。所以这一篇我直接换了个思路不再单独讲某个功能而是带着你从零搭一个完整的 2D 俯视角射击生存游戏原型。这个题材踩点踩得非常准玩家移动、鼠标瞄准、子弹发射、敌人生成、碰撞伤害、UI 反馈、难度递增几乎把 2D 游戏的核心系统全过了一遍而且每个系统之间都有真实的依赖关系做完之后你对 Godot 的整体工作方式会有一个很扎实的理解。这个原型适合两类人一类是已经学过 Godot 基础语法、想通过一个完整项目把节点和脚本串起来的新手另一类是手里有点 2D 小游戏经验、想看看别人怎么组织俯视角射击项目结构的老手。我不会绕弯子直接把工程结构、脚本代码、碰撞层设置和常见的坑都摆出来争取让你照着敲完就能跑起来。1. 内容整体设计与思路拆解1.1 第15篇为什么选“俯视角射击生存”这个题材先说题材选择的逻辑。GameMaker 或者 RPG Maker 里做 2D 游戏玩法框架往往已经帮你定死了但 Godot 不一样它给的是场景树和节点具体玩法全看你怎么组合。对一个系列教程来说题材必须能把之前讲过的知识点自然地带进一个完整流程里。俯视角射击生存恰好是覆盖面最广的玩法之一玩过《吸血鬼幸存者》这类游戏的人一看就懂玩家在封闭场景里移动敌人持续刷新涌向玩家玩家开枪消灭敌人赚分数随着时间推移敌人越来越多、越来越快直到玩家倒下。这个玩法在 Godot 里会用到这样几个核心机制玩家控制处理输入、速度、朝向和移动碰撞。鼠标瞄准把鼠标位置映射成角色的旋转角度。子弹系统生成子弹实体、设置飞行方向、检测命中。敌人 AI追踪玩家位置、持续移动、制造伤害。动态生成用 Timer 定期刷怪按时间或击杀数调节难度。UI 状态血量、得分、游戏结束界面的数据联动。场景切换与全局状态用 Autoload 单例管理跨场景数据。如果换成平台跳跃或者解谜玩法这些机制只用到其中一半不利于把系列教程前面积累的知识点串起来。所以“生存射击”并不是随便选个热门品类而是为了让每一个重要知识点都能在这个小项目里找到落点。1.2 先用一句话把项目结构说清楚很多新手不敢做完整项目是因为脑子里没有一张结构图。其实 Godot 项目的结构非常简单粗暴一个主场景里面挂着几个“管理器”节点玩家和敌人都是动态实例化的子场景。做这个原型时我的目录大概是这样的res:// game.tscn # 主场景挂刷怪管理器和UI GameManager.gd # Autoload 单例管理全局血量、得分、信号 player/ player.tscn player.gd enemy/ enemy.tscn enemy.gd bullet/ bullet.tscn bullet.gd主场景里的节点树是这样Game (Node2D) ├── World (TileMap 或 StaticBody2D 墙壁) ├── EnemySpawner (Node2D) │ ├── SpawnTimer (Timer) │ └── enemies 容器直接 add_child 动态挂进来 └── UI (CanvasLayer) ├── ScoreLabel ├── HpLabel └── GameOverPanel玩家和子弹、敌人都是从独立场景实例化出来的。主场景只负责两件事提供初始环境、接收动态生成的对象。这种结构的好处是职责清晰刷怪逻辑、玩家逻辑、UI 逻辑互不干扰后面你想改玩家建模或者换敌人外观只需要改对应场景文件不牵连其他系统。2. 核心细节解析与实操要点2.1 移动和朝向CharacterBody2D 的正确用法俯视角射击里的角色移动最忌讳的就是直接改position。我见过不少刚上手 Godot 的同学写position velocity * delta跑起来确实能动但一旦场景里有墙壁角色就会直接穿过去后面再加碰撞检测会非常痛苦。Godot 专门提供了一个角色类CharacterBody2D它内置了碰撞处理和回调你只需要设置velocity然后在_physics_process里调用move_and_slide()它会自动解决“贴着墙滑动”这类物理交互。玩家的移动输入我用的是Input.get_vector这个函数是 Godot 4 里非常好用的输入封装它会把 WASD 或者方向键的输入直接归一化为一个方向向量斜着走的时候自动归一化不需要你手动做向量标准化。给一个最小可跑的示例extends CharacterBody2D export var speed : 300.0 func _physics_process(_delta: float) - void: var input_dir : Input.get_vector(move_left, move_right, move_up, move_down) velocity input_dir * speed move_and_slide()这里有个细节容易忽略Input.get_vector里的四个参数对应的是你在“项目设置 - 输入映射”里配置的动作名。如果你没有配置move_left这些自定义动作直接用ui_left之类的内置动作也可以但后续动作名会乱所以我建议统一建一套自己的命名move_left ← A / 左方向键 move_right ← D / 右方向键 move_up ← W / 上方向键 move_down ← S / 下方向键 fire ← 鼠标左键配置好之后角色朝向鼠标也只要一行代码在_physics_process里用global_position.direction_to(get_global_mouse_position()).angle()拿到玩家到鼠标的方向角度然后直接赋给rotation。这里要注意Godot 2D 的默认朝向右方0 度而俯视角游戏的贴图通常默认朝上如果发现角色转了个奇怪的角度就把精灵贴图的Rotation预先转 90 度或者用美术资源本身朝右。这个小问题在我几乎每一个项目里都会出现提前说一声能省很多调试时间。2.2 子弹与手感冷却、朝向和销毁子弹在原型里我用的是Area2D而不是CharacterBody2D。Area2D本身就是用来做“检测区域”的它不需要处理物理移动只负责告诉你有谁进入了自己的范围这在子弹命中的场景里非常合适。子弹的碰撞形状设成一个小圆检测到敌人之后调用敌人的受伤方法然后自杀。先说手感问题。很多新手做射击游戏第一版会允许玩家以每秒 60 帧的速率疯狂开火结果子弹铺满屏幕性能直接崩掉。解决方式就是一个冷却计时器开火动作触发后启动 Timer在它超时之前不允许再次开火。按下鼠标左键的瞬间玩家逻辑判断冷却是否结束如果还在冷却中就直接 return。这个冷却间隔直接决定了射击手感原型里 0.15 秒左右会比较舒服太短会让子弹密度过高太长会显得角色很笨。子弹自身的移动不要用move_and_slide因为Area2D没有这个函数直接在_physics_process里做位置增量即可extends Area2D var direction : Vector2.RIGHT export var bullet_speed : 700.0 onready var life_timer: Timer $LifeTimer func _ready() - void: rotation direction.angle() life_timer.timeout.connect(queue_free) life_timer.start(2.0) func _physics_process(delta: float) - void: global_position direction * bullet_speed * delta这里的关键点有两个。第一local_position改起来有风险如果子弹被挂到玩家节点下面玩家一动子弹也跟着动所以动态生成时我习惯直接加到主场景而不是玩家子节点下。第二子弹必须有生命周期不管有没有命中2 秒后自动销毁。很多新手会漏掉这一步结果子弹飞出屏幕外后依然留在节点树里玩两分钟游戏节点数爆炸性能骤降。Timer的timeout.connect(queue_free)是清理这类临时节点最省事的办法。命中逻辑可以写得简单直接在子弹的_ready里连接body_entered信号回调判断碰撞对象是否属于enemy组是就调用take_damage方法然后销毁自己。这里的判断用组而不是类型好处是敌人以后换成别的场景只要保留enemy组和take_damage方法子弹逻辑完全不用改。2.3 敌人的行为生成、追踪和伤害敌人这块容易掉进“物理碰撞”的坑。如果敌人在_physics_process里也用move_and_slide()并且它的碰撞形状和玩家在同一碰撞层那么敌人追玩家的时候会互相顶着一堆敌人挤成肉团看起来特别假。这个原型里我故意不让敌人使用物理移动而是直接改global_positionextends CharacterBody2D export var speed : 160.0 var player: Node2D func _ready() - void: player get_tree().get_first_node_in_group(player) func _physics_process(delta: float) - void: if not is_instance_valid(player): return var direction : global_position.direction_to(player.global_position) global_position direction * speed * delta注意敌人仍然继承CharacterBody2D也带有CollisionShape2D但它不调用move_and_slide()所以不会被墙壁阻挡也不会推挤玩家。它的碰撞形状只用来被子弹和玩家的受击检测区域识别相当于一个“可以被扫描到的标签”。这是原型阶段的偷懒方案优点是代码极少、行为稳定缺点是敌人会直接穿墙。如果你做正式项目可以考虑后面把移动改成导航网格或者至少加一个简单的障碍回避逻辑。但对一个教程原型来说“能跑、能玩、逻辑清晰”优先级最高。伤害流程是通过玩家身上的一个子节点DamageAreaArea2D完成的。这个区域的碰撞半径比角色本体稍微大一点点每当有敌人进入就触发body_entered信号然后在回调里调用全局管理器扣血。为了防止玩家站在敌人堆里一帧掉光血量我加了一个 1 秒的无敌计时器受伤后 1 秒内不会再触发扣血。这个“受击无敌帧”是动作游戏和射击游戏里非常经典的设计少了它体验会差很多。3. 实操过程与核心环节实现3.1 玩家场景搭建与输入映射先新建一个Player场景根节点类型选CharacterBody2D命名为Player。给Player加上CollisionShape2D形状选CapsuleShape2D或CircleShape2D尺寸根据你的角色贴图调整。这里有个容易踩的坑碰撞形状的尺寸千万别比贴图可视区域大否则玩家会被空气墙卡住看起来就像角色在墙外面漂移。我一般会把碰撞圆半径设置成角色贴图宽度的 40% 左右既能稳定站住又不会太容易卡墙。再加两个子节点Sprite2D放角色贴图如果没有贴图就用一个彩色矩形代替。DamageAreaArea2D加一个CollisionShape2D半径比主碰撞大 10 像素用来检测敌人体积。ShootCooldownTimer单次模式超时后重置射击冷却。回到玩家脚本完整代码如下extends CharacterBody2D export var speed : 300.0 export var bullet_scene: PackedScene onready var muzzle: Marker2D $Muzzle onready var shoot_cooldown: Timer $ShootCooldown onready var damage_area: Area2D $DamageArea onready var invincible_timer: Timer $InvincibleTimer func _ready() - void: add_to_group(player) damage_area.body_entered.connect(_on_damage_area_body_entered) func _physics_process(delta: float) - void: var input_dir : Input.get_vector(move_left, move_right, move_up, move_down) velocity input_dir * speed move_and_slide() rotation global_position.direction_to(get_global_mouse_position()).angle() func _unhandled_input(event: InputEvent) - void: if event.is_action_pressed(fire): shoot() func shoot() - void: if not shoot_cooldown.is_stopped(): return shoot_cooldown.start() var bullet : bullet_scene.instantiate() bullet.global_position muzzle.global_position bullet.direction (get_global_mouse_position() - muzzle.global_position).normalized() get_tree().current_scene.add_child(bullet) func _on_damage_area_body_entered(body: Node2D) - void: if body.is_in_group(enemy) and invincible_timer.is_stopped(): GameManager.damage_player(10) invincible_timer.start()有几个细节值得展开。_unhandled_input只处理没有被 UI 或其他节点消费的输入如果后面你在 CanvasLayer 上加了暂停菜单按钮按钮点击不会触发开火这是很合理的行为。Marker2D是 Godot 专门用来标记位置的节点放在角色贴图枪口的位置子弹生成时用它取世界坐标这样子弹就总是从枪口冒出来而不是从角色中心。add_to_group(player)是给敌人用的敌人 AI 每帧都在找这个组里的节点。输入映射怎么配在菜单栏切到“项目 - 项目设置”选“输入映射”标签页在下面的输入框里输入move_left点“添加”然后展开新动作点“”选“按键”在键盘上按 A再重复一步把左方向键也加进去。其他动作同理。fire动作添加时选“鼠标按钮”按键选“左键”。这一步很多人会漏掉漏了之后游戏里按方向键根本没反应报错信息也不直观排查起来特别费时间。3.2 全局状态与UI用 Autoload 管理血量和得分小游戏的血量和得分看起来简单但如果你直接把它们存在玩家节点里玩家一死数据全没了存在刷怪管理器里UI 访问起来又很别扭。Godot 专门为这种“多个场景都要访问的全局数据”提供了一个机制Autoload。在项目设置里选“全局”标签页添加一个脚本路径比如res://GameManager.gd给它起名GameManager之后在任何脚本里写GameManager.xxx()都能直接访问它不需要实例化也不需要onready。这个单例脚本我做成这样extends Node signal score_changed(new_score) signal hp_changed(new_hp) signal game_over var score : 0 var max_hp : 100 var hp : max_hp func add_score(amount: int) - void: score amount score_changed.emit(score) func damage_player(amount: int) - void: if hp 0: return hp max(0, hp - amount) hp_changed.emit(hp) if hp 0: game_over.emit() func reset() - void: score 0 hp max_hp score_changed.emit(score) hp_changed.emit(hp)UI 部分用CanvasLayer做根节点就对了。CanvasLayer是独立于 2D 世界坐标的它不会跟随摄像机移动适合放得分、血量这类不随场景滚动的界面元素。我在 UI 脚本里连接信号extends CanvasLayer onready var score_label: Label $ScoreLabel onready var hp_label: Label $HpLabel onready var game_over_panel: Control $GameOverPanel func _ready() - void: GameManager.score_changed.connect(_on_score_changed) GameManager.hp_changed.connect(_on_hp_changed) GameManager.game_over.connect(_on_game_over) GameManager.reset() func _on_score_changed(new_score: int) - void: score_label.text 得分: %d % new_score func _on_hp_changed(new_hp: int) - void: hp_label.text 生命: %d % new_hp func _on_game_over() - void: game_over_panel.visible true为什么用信号而不是直接在 UI 的_process里每帧读GameManager.score因为信号是事件驱动的只有分数变化时才刷新文本性能更好而且逻辑更清晰你不需要知道 UI 在什么时候该刷新只管在数据变化时发信号。我见过很多新手把 UI 文本刷新写进_process结果每帧都在改 Label造成无谓的 CPU 开销还容易产生“文字闪烁”的假象。3.3 刷怪管理器与难度递增刷怪管理器是一个挂在主场景下的Node2D它只负责生成敌人、记录分数、控制难度。核心节点是一个Timerwait_time初始设 2 秒每次超时生成一个敌人然后把wait_time稍微减一点。这样随着时间推移敌人生成间隔越来越短压力自然上升。extends Node2D export var enemy_scene: PackedScene export var spawn_distance_min : 500.0 export var spawn_distance_max : 800.0 onready var spawn_timer: Timer $SpawnTimer onready var player: Node2D get_tree().get_first_node_in_group(player) func _ready() - void: spawn_timer.timeout.connect(_on_spawn_timer_timeout) spawn_timer.start(2.0) func _on_spawn_timer_timeout() - void: if not is_instance_valid(player): return spawn_timer.wait_time max(0.4, spawn_timer.wait_time - 0.02) var enemy : enemy_scene.instantiate() var angle : randf() * TAU var distance : randf_range(spawn_distance_min, spawn_distance_max) var pos : player.global_position Vector2.RIGHT.rotated(angle) * distance enemy.global_position pos add_child(enemy) enemy.take_damage.connect(_on_enemy_killed) # 假设敌人受伤方法里发出信号 func _on_enemy_killed(enemy: Node) - void: GameManager.add_score(10)这里有一个生成位置的设计细节敌人必须在玩家视野边缘之外生成。如果直接在玩家脚下刷怪玩家会毫无反应就被咬掉半管血非常劝退。我用的方法是从玩家位置出发随机一个方向、随机一段距离保证新敌人在 500 像素以外出现。同时还要考虑场景边界如果地图是 2000x1200 的矩形生成位置做完随机之后用clampf限制在边界内否则敌人会刷到场景外的虚空里去。至于“敌人死亡后通知刷怪管理器”这件事我建议用信号而不是直接调用管理器的方法。敌人脚本里定义一个signal died在_ready里让管理器连接或者敌人内部挂一个died.connect(GameManager.add_score.bind(10))。用信号的好处是敌人完全不需要知道刷怪管理器的存在解耦很干净。这个原型里为了写得直观一点直接在敌人受伤逻辑里调用GameManager.add_score(10)也行但你心里要清楚正式项目里这种硬引用会越积越多最后改一个系统要牵连好几个文件信号才是迟早要掌握的通信手段。3.4 导出 HTML5 并在浏览器里跑起来游戏做完是本地运行的但分享给朋友玩、或者放到自己的博客上展示最常见的方式就是导出成 HTML5。Godot 对 Web 平台的支持是开箱即用的你不需要写任何网页代码直接导出就行。具体操作顶部菜单选“项目 - 导出”如果没有导出配置先“添加预设”选Web。在导出面板里设置起始场景、输出文件名比如index.html。然后点“导出项目”生成一个大目录里面有index.html、.wasm文件和一个pck数据文件。导出完浏览器双击index.html有很大概率是个白屏。这不是你的代码写错了而是浏览器安全策略限制直接双击打开的本地 HTML 无法通过fetch加载同目录的 pck 资源文件。最简单的解决办法是起一个本地静态服务器让页面通过http://localhost访问。如果你装了 Python在导出目录开个终端运行python -m http.server 8080然后浏览器访问http://localhost:8080。没有 Python 的话用 VS Code 的 Live Server 插件也可以。还有一个导出设置里的坑在导出面板的“选项”标签页下有个“压缩模式”默认可能是 gzip。如果你本地服务器没配 gzip 响应头游戏会加载失败或者白屏。本地调试时建议直接选“不压缩”等放到支持 gzip 的服务器上再改回来。这个细节我踩过一次排查了半个小时最后发现就是压缩配置不匹配。4. 常见问题与排查技巧实录4.1 运行期最容易踩的5个坑每次带朋友入门做 2D 游戏我都会反复说这句话问题不可怕可怕的是不知道怎么定位。下面这张表是我拿这个原型实测整理出来的基本都是新手高频问题。现象可能原因处理办法玩家按下方向键没反应输入映射里没有配置对应的动作名去项目设置里确认move_left等动作存在且与脚本参数一致角色被空气墙卡住碰撞形状比贴图可视范围大缩小 CollisionShape2D或者打开该形状的可视化调试子弹打出去方向不对子弹的direction没有归一化确认用了normalized()或者direction_to已返回归一化向量敌人没有出现刷怪 Timer 没有 start或者玩家组名错误检查_ready里是否调用spawn_timer.start()get_first_node_in_group(player)是否有匹配导出网页白屏压缩模式选了 gzip 但本地服务器不支持临时把压缩模式改为“不压缩”或配置服务器的 gzip 支持有一条我自己早期做 2D 项目经常栽的跟头单独拎出来说角色消失。玩家受击死亡后我直接调用了queue_free()但刷怪管理器、子弹和敌人脚本里还在get_tree().get_first_node_in_group(player)找玩家找不到就返回null下一帧敌人 AI 访问player.global_position直接报错。解决办法是在敌人脚本里用is_instance_valid(player)做保护返回 false 就说明玩家已经死了直接跳过移动逻辑。这个保护写不写决定了游戏结束后控制台会不会刷一堆红色报错。还有一个关于物理层的问题敌人虽然是CharacterBody2D但因为我没用move_and_slide()它不会阻挡玩家。但如果你在某个版本里手滑加了一句move_and_slide()玩家瞬间会被一堆敌人卡得动弹不得。遇到这种情况第一反应就去看脚本里是否调用了物理移动函数。这个排查经验靠代码审查比靠眼睛看画面要快得多。4.2 两个能救命的调试工具Godot 编辑器不是只能写代码和摆节点它内置的调试器比很多人想象中强得多。运行游戏的时候编辑器底部会有一个调试面板里面有两个工具是我的日常救星。第一个是“远程场景树”。运行时打开它你能看到实际运行时整棵节点树长什么样包括动态生成出来的敌人、子弹、临时节点。如果游戏里发现某个节点没有被释放切到远程树里看层级就能立刻发现是哪里在持续挂节点。我之前排查子弹泄漏问题就是靠这个工具发现子弹被加到了玩家节点下面而不是主场景结果玩家旋转的时候子弹跟着乱飞还一直不清除。第二个是“监视器”标签页里的性能指标。运行游戏前开启监视器然后观察节点数这个指标。正常情况下节点数量应该在一个稳定区间波动如果曲线持续向上而不回落说明有大量节点只创建不销毁。你再用远程场景树找到是哪类节点问题就解决了。另外写敌人 AI 的时候建议把Global Position调试信息打开。选中敌人节点检查“调试”栏里的“显示变换信息”运行时你能直接看到每个敌人的坐标和旋转角度。我曾经遇到过敌人追玩家时方向刚好相反看起来就像所有敌人在后退就是因为direction_to用反了方向从敌人指向玩家应该是global_position.direction_to(player.global_position)如果你写成反过来的方向敌人就会朝远离玩家的方向走。这类方向性问题没有坐标视觉化纯靠看画面真不一定能看出来。5. 从原型到完整项目的三个扩展点5.1 波次系统把刷怪数据抽成配置做完原型之后如果你想让玩法更有结构性首先值得加的就是波次系统。现在的刷怪逻辑一直按同一个节奏随机生成玩久了会腻。波次系统的思路是把每一波的敌人数量、类型、生成间隔、奖励分数定义成一个数据表当前波打完再进入下一波。我把这种数据定义成WaveConfig资源来管理。Godot 里可以用Resource脚本创建自定义资源然后为每一波生成一个.tres配置文件。这样策划改数据和程序改逻辑就能分开不用反复动脚本。class_name WaveConfig extends Resource export var enemy_count : 10 export var spawn_interval : 1.2 export var enemy_speed : 160.0 export var reward_score : 10刷怪管理器改成逐波读取配置当前波剩余敌人清零后再加载下一波。这个改动不算大但游戏节奏会立刻上一个台阶。需要注意的是资源实例和共享引用enemy_scene这种PackedScene在多个配置里共用没问题但如果配置里有数组属性要留意 Godot 默认是共享引用改一个配置可能影响其他配置。5.2 动画与特效用 AnimatedSprite2D 替换静态贴图原型阶段为了跑通逻辑我全程用矩形和色块代替美术资源。做可玩版本第一步就是换成动画。Godot 2D 动画最省事的节点是AnimatedSprite2D它本质上是多个帧贴图的播放器。你把一系列SpriteFrame拖进去设置动画名和帧率脚本里调用play(idle)或者play(run)就能切换。俯视角角色动画有个细节要注意角色移动时不应该只有位置变化贴图朝向也要跟着移动方向走。但我的玩家是瞄准鼠标的所以角色朝向由鼠标决定而不是移动方向。在这种情况下动画帧一般只做四个方向的变体上、下、左、右然后根据当前rotation角度选择播放哪套帧。这里不推荐用AnimationTree功能虽然强但对小项目来说太重了直接用AnimatedSprite2D加rotation判断就够。子弹命中特效可以用GPUParticles2D脚本里在击中位置实例化一个单次粒子节点等粒子播放完再queue_free()。粒子节点也需要生命周期管理不然和子弹一样会堆积这个习惯从原型到正式项目都得保持。5.3 存档与重开会话Autoload 的进阶用法扩展的最后一步我建议做“重开”和“最高分存档”。因为GameManager是 Autoload 单例它常驻在场景切换之外天然适合存数据。把最高分写进GameManager然后在 UI 的_on_game_over里判断并展示。存档本身用FileAccess写一个 JSON 文件就行不需要引入额外依赖func save_data() - void: var data : { high_score: high_score } var file : FileAccess.open(user://save.json, FileAccess.WRITE) file.store_string(JSON.stringify(data)) file.close() func load_data() - void: if not FileAccess.file_exists(user://save.json): return var file : FileAccess.open(user://save.json, FileAccess.READ) var data: Dictionary JSON.parse_string(file.get_as_text()) high_score data.get(high_score, 0) file.close()重开游戏时给 UI 的 GameOverPanel 加一个“重新开始”按钮按钮回调里调用GameManager.reset()然后get_tree().reload_current_scene()重载主场景。这两步配合起来玩家的最高分就能跨对局保留整个项目也算有了一个完整的游戏闭环。做这套教程时我自己最大的体会是Godot 的上手门槛其实不高难点一直在“怎么把零散的节点和信号组合成一个有反馈的系统”。如果你也是按着这个系列一路做下来的现在你可以回过头去看最开始那几篇讲的场景树、信号、碰撞层应该会有完全不一样的理解。这个原型里我刻意保留了几个“以后要重构”的设计比如敌人穿墙、直接改 global_position 移动这些都不是最佳实践但我相信先把项目跑起来、再讨论优雅才是对新手最友好的路径。
返回列表