译:Gamemaker Studio 2.3 语法详解
译:highway★
译注:从2.3更新之后基本没怎么碰过GMS2,重新开启GMS2.3之后很多新加的东西都没咋看过对很多新东西有些恐惧,总是一拖又拖,这篇文章讲的很细致,比起看视频也节省一些时间。搬运过来,希望能对同样使用GMS2,特别是我这种对2.3比较懵逼的人有些帮助。
-----------------------------------------------------------------------------------------------------------------------------------
Chained accessors(链式访问器)
长期以来,GameMaker一直允许少量的 "访问器 ",
// 正常array操作: val = an_array[index]; an_array[index] = val; // 非写入时复制操作(non-copy-on-write): an_array[@index] = val; // 等同于array_set(an_array, index, val) // ds_map: val = a_map[?key]; // 等同于val = ds_map_find_value(a_map, key) a_map[?key] = val; // 等同于ds_map_set(a_map, key, val) // ds_list: val = a_list[|index]; // 等同于val = ds_list_find_value(a_list, index) a_list[|index] = val; // 等同于ds_list_set(a_list, index, val) // ds_grid: val = a_grid[#x, y]; // 等同于val = ds_grid_get(a_grid, x, y) a_grid[#x, y] = val; // 等同于ds_grid_set(a_grid, x, y, val)
GMS2.3这些基础上稍作了扩展,允许将它们链接在一起--所以我们现在可以这样写:
list_of_maps[|i][?"hi"] = "hello";
来替代以前的写法:
ds_map_set(ds_list_find_value(list_of_maps, i), "hi", "hello");
对于嵌套数据结构和多维数组,这么写很方便。
-----------------------------------------------------------------------------------------------------------------------------------
Array的改动
2D数组现在只是嵌套的1D数组,你可以更容易地创建更高维数的数组。
array_1d[0] = "hi!"; // 没有改动 array_2d[1][0] = "hi!"; // 以前这么写array_2d[0, 0] = "hi!" array_3d[2][1][0] = "hi!"; // 新加的! // ...等等
-----------------------------------------------------------------------------------------------------------------------------------
Structs
Structs就像实例(instance),但没有任何事件或内置变量。非常轻便。
我们可以通过使用{}来创建一个空结构。
var q = {}; show_debug_message(q); // { } q.hi = "hello!"; show_debug_message(q); // { hi : "hello!" } q.one = 1; show_debug_message(q); // { hi : "hello!", one: 1 } 你也可以通过指定名称预先填入一些字段 name: value: var q = { a: 1, b: 2 }; show_debug_message(q); // { b : 2, a : 1 } q.c = 3; show_debug_message(q); // { c : 3, b : 2, a : 1 }
与array类似,Structs由GMS2自动管理,这意味着你不必像对待实例那样明确地销毁它们。
Structs可以像之前我们在实例上那样的用法一样,比如我们可以 with(a_struct),尽管
我们不能以这种方式遍历struct中的每一个 "实例"--我们需要将它们添加到一个array或list中。
-----------------------------------------------------------------------------------------------------------------------------------
Structs as maps
与实例类似,struct有variable_struct_*函数用于动态管理其变量。
这使得struct可以作为ds_maps的垃圾收集替代物:
var q = { a: 1 }; variable_struct_set(q, "b", 2); variable_struct_set(q, "$", "dollar"); show_debug_message(q); // { $ : "dollar", a : 1, b : 2 } show_debug_message(q.b); // 2 show_debug_message(variable_struct_get(q, "a")); // 1 show_debug_message(variable_struct_get(q, "$")); // dollar
为了方便,2.3.1开始通过添加 struct[$key] 访问器进一步扩展了这一点:
var q = { a: 1 }; q[$"b"] = 2; // 等同于variable_struct_set(q, "b", 2) var v = q[$"b"]; // 等同于variable_struct_get(q, "b")
结合array,这允许复制大多数数据结构而无需明确的销毁它们。
一些注意事项:
- 直接 (a.b) 读/写比使用 variable_struct_* 函数更快,可用于您确定结构具有变量的情况。否则 variable_struct_* 函数的性能与 ds_map 非常相似。
- 与 ds_map 不同,ds_map 几乎可以接受任何key值,但struct变量名称是字符串,因此 variable_struct_set(q, 4, "four") 与 variable_struct_set(q, "4", "four") 相同。
- Structs for JSON
- 2.3.1增加了json_stringify和json_parse函数,它们与现有的json_encode和json_decode很相似,但使用的是struct和array,而不是像之前的和map和list。
我们可以这样:
var o = { a_number: 4.5, a_string: "hi!", an_array: [1, 2, 3], a_struct: { x: 1, y: 2 } }; show_debug_message(json_stringify(o));
这会输出下面的信息:
{ "a_string": "hi!", "an_array": [ 1, 2, 3 ], "a_struct": { "x": 1, "y": 2 }, "a_number": 4.5 }
并将该字符串传递给 json_parse 会返回给我们一个嵌套struct。
-----------------------------------------------------------------------------------------------------------------------------------
Functions
以前,每个脚本资源都将包含在调用时要运行的单个代码片段。
像下面这样:
/// array_find_index(array, value) /// @param array /// @param value var _arr = argument0; var _val = argument1; var _len = array_length_1d(_arr); for (var _ind = 0; _ind < _len; _ind++) { if (_arr[_ind] == _val) return _ind; } return -1;
但现在,情况不同了 - 我们可以在同一个脚本资源中有多个独立的片段,通过使用 function <name>() {<code>} 语法来区分:
/// @param array /// @param value function array_find_index() { var _arr = argument0; var _val = argument1; var _len = array_length_1d(_arr); for (var _ind = 0; _ind < _len; _ind++) { if (_arr[_ind] == _val) return _ind; } return -1; } /// @param array /// @param value function array_push() { var _arr = argument0; var _val = argument1; _arr[@array_length(_arr)] = _val; }
其工作原理如下:
脚本中的 function name(){} 成为一个全局函数,这相当于 2.3 之前的工作方式
function name() { // code here } function(){} 可以用作表达式,允许您执行 explode = function() { instance_create_layer(x, y, layer, obj_explosion); instance_destroy(); }
在 Create 事件中,甚至将其用作函数调用中的参数!
layer_script_begin("MyLayer", function() { shader_set(sh_brightness); shader_set_uniform_f(shader_get_uniform(sh_brightness, "u_bright"), 1); }); layer_script_end("MyLayer", function() { shader_reset(); });
在另一个函数中/在脚本外的function name(){} 等效于:
self.name = function(){};
可以更方便使用。
任何在脚本内但在函数外的其他代码都将在游戏启动时运行;获取/设置变量将像global.variable一样工作:
show_debug_message("Hello!"); // 在创建任何实例之前显示 variable = "hi!"; // sets global.variable // ...函数定义
允许它被用于任何初始设置。
然而,请注意,这个程序在进入第一个房间之前就已经运行了,所以,如果你想生成实例,你会想使用room_instance_add。
作为一个令人愉快的奖励,你现在可以不用script_execute来调用存储在变量中的函数。
function scr_hello() { show_debug_message("Hello, " + argument0 + "!"); }/// ... var hi = scr_hello; script_execute(hi, "you"); hi("you"); // 新的! 与上面效果一样
现在,开始进行更有趣的补充。
-----------------------------------------------------------------------------------------------------------------------------------
命名参数
函数语法的引入还带来了另一个奇妙的补充--命名的参数!
以前,咱得这么写:
function array_push() { var _arr = argument0, _val = argument1; _arr[@array_length(_arr)] = _val; }
或者
function array_push() { var _arr = argument[0], _val = argument[1]; _arr[@array_length(_arr)] = _val; }
现在咱只需要这么写:
function array_push(_arr, _val) { _arr[@array_length(_arr)] = _val; }
这使得可选参数也更容易--任何没有提供给脚本的命名参数都将被设置为未定义,这意味着咱可以这样写:
function array_clear(_arr, _val) { if (_val == undefined) _val = 0; // 之前得这么写: var _val = argument_count > 1 ? argument[1] : 0; var _len = array_length(_arr); for (var _ind = 0; _ind < _len; _ind++) _arr[@_ind] = _val; return _arr; }
-----------------------------------------------------------------------------------------------------------------------------------
静态变量
这些变量类似于C++中的局部静态变量。
也就是说,静态变量是持久的,但只在它所声明的函数中可见。
这对任何需要函数特定状态的情况来说都是很好的。
function create_uid() { static next = 0; return next++; } function scr_hello() { show_debug_message(create_uid()); // 0 show_debug_message(create_uid()); // 1 show_debug_message(create_uid()); // 2 }
静态变量在执行中第一次到达时被初始化。
function scr_hello() { // show_debug_message(some); // error - not defined static some = "OK!"; show_debug_message(some); // "OK!"" }
因此,静态变量通常位于其各自函数的开头。
-----------------------------------------------------------------------------------------------------------------------------------
Methods/function绑定
这个功能与基于ECMAScript语言中的Function.bind完全相同。
一个函数可以被 "绑定 "到某个东西上,这将在该函数调用中把自己变成那个值,把原来的自己推到其他地方(就像with语句那样)。
这意味着,如果你有
// obj_some, Create event function locate() { show_debug_message("I'm at " + string(x) + ", " + string(y) + "!"); }
, 你可以同时进行
var inst = instance_create_depth(100, 200, 0, obj_some); inst.locate(); // 100, 200 var fn = inst.locate; fn(); // also 100, 200!
因为你得到的函数引用是与该实例绑定的。
一个函数可以被绑定到一个struct、一个实例ID,或者什么都没有(未定义)。
没有绑定到任何东西的函数会像2.3之前的脚本那样保留self/other。
然而,如果一个函数没有被绑定到任何东西上,但你以some.myFunc的形式调用它,它将被当作被绑定到some上。
自动绑定的工作原理如下:
- 在脚本中的function name(){}不绑定任何东西,保持与2.3之前版本的兼容性。
- 绑定到self的function name(){}使得实例方法的定义更加简单(也就是说,你可以在Create事件中拥有一系列的函数定义)。
- static name = function(){}也没有绑定任何东西,这很好,因为你不希望静态函数绑定到父函数被调用的第一个实例。
- 任何其他使用name = function(){}的行为都会被绑定到self。
函数可以使用方法内置函数进行[重新]绑定。一个已经被绑定的函数(function)在形式上被称为 "方法(method)"(因此被称为内置函数)。
总的来说,这不仅对实例/结构特定的函数很方便,而且还可以 "创建 "与一些自定义上下文绑定的函数
例如,你可以写一个函数,返回一个生成增量ID的函数(就像前面提到的的static),并且让每个这样的返回函数的ID是独立的。
function create_uid_factory() { var _self = { next: 0 }; var _func = function() { return self.next++; }; return method(_self, _func); } // var create_entity_uid = create_uid_factory(); var create_network_uid = create_uid_factory(); repeat (3) show_debug_message(create_entity_uid()); // 0, 1, 2 show_debug_message(create_network_uid()); // 0
-----------------------------------------------------------------------------------------------------------------------------------
函数调用
由于现在函数可以存储在任何地方,你也可以从任何地方调用它们。
scr_greet("hi!"); // 跟以前一样 other.explode(); // 这样可以 init_scripts[i](); // 这样也可以 method(other, scr_some)(); // 对'other'执行'scr_some',不用加'with'
-----------------------------------------------------------------------------------------------------------------------------------
内置函数引用
可以这样
var f = show_debug_message; f("hello!");
而且我们可以自动为内置函数建立索引。
var functions = {}; for (var i = 0; i < 10000; i++) { var name = script_get_name(i); if (string_char_at(name, 1) == "<") break; functions[$name] = method(undefined, i); show_debug_message(string(i) + ": " + name); } // `functions` now contains name->method pairs
这会输出:
0: camera_create 1: camera_create_view 2: camera_destroy ... 2862: layer_sequence_get_speedscale 2863: layer_sequence_get_length 2864: sequence_instance_exists
索引对于调试和脚本工具来说是非常方便的--例如,GMLive现在使用这种机制,而不是有一个充满脚本的庞大文件来包装每一个已知的内置函数。
-----------------------------------------------------------------------------------------------------------------------------------
Constructor(构造函数)
Constructor是一个标有Constructors后缀关键字的函数。
function Vector2(_x, _y) constructor { x = _x; y = _y; }
这使你能够做到
var v = new Vector2(4, 5); show_debug_message(v.x); // 4 show_debug_message(v); // { x: 4, y: 5 }
简而言之,new关键字可以自动创建一个空结构,为它调用构造函数,然后返回它。就像其他编程语言中的类一样! 但还有更多。
Static variables静态变量
GMS2将把constructor中的静态变量视为存在于由它创建的struct实例中,只要struct实例没有覆盖该变量。
这类似于变量定义对对象的作用,或原型在其他编程语言中的作用(如JavaScript原型或Lua的元数据)。
这可以用于默认值(然后可以覆盖),但最重要的是,可以向struct添加method,而不需要在每个struct实例中实际存储:
function Vector2(_x, _y) constructor { x = _x; y = _y; static add = function(v) { x += v.x; y += v.y; } } // ... 然后 var a = new Vector2(1, 2); var b = new Vector2(3, 4); a.add(b); show_debug_message(a); // { x : 4, y : 6 }
注意:如果您想在constructor中直接覆盖静态变量(而不是在其中的function中),您需要使用 self.variable 来区分static variable和new struct的变量:
function Entity() constructor { static uid = 0; self.uid = uid++; }
(这将给每个实体一个唯一的ID)
-----------------------------------------------------------------------------------------------------------------------------------
Inheritance(继承)
一个constructor可以使用 : Parent(<arguments>) 语法从另一个constructor继承:
function Element(_x, _y) constructor { static step = function() {}; static draw = function(_x, _y) {}; x = _x; y = _y; } function Label(_x, _y, _text) : Element(_x, _y) constructor { static draw = function(_ofs_x, _ofs_y) { draw_text(_ofs_x + x, _ofs_y + y, text); }; text = _text; }
这将首先调用父constructor,然后再调用子constructor。
在子constructor中定义的静态变量优先于在父constructor中定义的静态变量,这就为覆盖父字段提供了一种方法--因此,用上述方法,你可以做到
var label = new Label(100, 100, "Hello!"); label.step(); // 调用父step函数 label.draw(5, 5); // 调用子draw函数
如果你确实需要父method是可调用的,你可以在覆写子method之前存储它,比如说
function Label(_x, _y, _text) : Element(_x, _y) constructor { static __step = step; // 现在引用父constructor的step函数 static step = function(_ofs_x, _ofs_y) { __step(); // 调用父constructor的step函数 // ... }; // ... }
-----------------------------------------------------------------------------------------------------------------------------------
异常处理
GameMaker函数的结构通常是不抛出错误的,除非它肯定是你的错--所以,例如,试图打开一个不存在的文本文件将返回一个特殊的索引-1,但试图从一个无效的索引读取将抛出一个错误。
不过,写允许失败的代码还是很方便的,不需要在过程的每一步插入安全检查。现在你可以了! 其工作原理如下。
try { // (可能引发错误的代码) var a = 1, b = 0; a = a div b; // 导致 "除以零 "的错误 show_debug_message("this line will not execute"); } catch (an_exception) { // 对错误信息做一些事情(或不做),这些信息是 // 现在存储在局部变量an_exception中。 show_debug_message(an_exception); }
"内置 "错误是带有几个变量的结构。
- message:一个字符串,包含对错误的简短描述。例如,如果你试图做整数除以0,它将是 ""DoRem :: Divide by zero"。
- longMessage:一个对错误和callstack有较长描述的字符串。如果你不处理这个错误,这将出现在内置的错误弹出窗口。
- Stacktrace:表示调用堆栈的字符串数组 - 导致问题点的一连串函数名。当从IDE或使用YYC运行时,行号将包含在每个函数名之后(例如gml_Script_scr_hello(第5行))。
- script: (技术上的)错误起源的脚本/函数的名称。这与抓取 stacktrace 中的第一项没有太大区别。
你也可以抛出你自己的异常--可以通过调用show_error和错误文本。
try { show_error("hey", false); } catch (e) { show_debug_message(e.message); // "hey" }
或通过使用throw关键字(允许任意的值被 "抛出")。
try { throw { message: "hey", longMessage: "no long messages today", stacktrace: debug_get_callstack() } } catch (e) { show_debug_message(e); // 输出上述struct }
Try-catch块可以嵌套在同一个或不同的脚本中。
当这种情况发生时,最近的捕获块将被触发。
如果你不想处理一个异常,你可以 "重新抛出 "它。
try { try { return 10 / a_missing_variable; } catch (e) { if (string_pos("DoRem", e.message) != 0) { show_debug_message("Caught `" + e.message + "` in inner catch!"); } else { throw e; } } } catch (e) { show_debug_message("Caught `" + e.message + "` in outer catch!"); }
如果一个异常没有被捕获,你会得到熟悉的错误弹出窗口。除非...
-----------------------------------------------------------------------------------------------------------------------------------
exception_unhandled_handler
在可以被认为是最后一道防线的情况下,GMS2现在还提供了一个函数,当一个异常没有被捕获,你的游戏即将关闭时,这个功能将被调用。这覆盖了默认的错误弹出窗口。
exception_unhandled_handler(function(e) { show_message("Trouble!\n" + string(e.longMessage)); }); show_error("hey", true);
正如文档所指出的,在这一点上你能做的不多,但你可以将错误文本(连同任何可能证明有用的上下文)保存到一个文件中,这样你就可以在游戏开始时加载它,并为用户提供一个报告。
-----------------------------------------------------------------------------------------------------------------------------------
较小的添加物
主要是便利功能。
String functions
增加了string_pos_ext、string_last_pos和string_last_pos_ext,以处理从偏移量和/或从字符串末尾开始搜索子串的问题,这对解析数据很有帮助--例如,见我以前的 "在分隔符上分割字符串 "的帖子。
-----------------------------------------------------------------------------------------------------------------------------------
Array functions
增加了一些数组函数来处理数组。
array_resize(array, newsize) 这将一个数组的大小调整为新的大小,要么在数组的末尾添加零,要么删除元素以满足大小。
var arr = [1, 2, 3]; array_resize(arr, 5); show_debug_message(arr); // [1, 2, 3, 0, 0] array_resize(arr, 2); show_debug_message(arr); // [1, 2]
使得其他各种实用函数得以实现。
array_push(array, ...values) 将一个或多个值添加到一个数组的末端。
var arr = [1, 2, 3]; array_push(arr, 4); show_debug_message(arr); // [1, 2, 3, 4] array_push(arr, 5, 6); show_debug_message(arr); // [1, 2, 3, 4, 5, 6] array_insert(array, index, ...values)
array_insert(array, index, ...values) 在一个数组中的偏移处插入一个或多个值。
var arr = [1, 2, 3]; array_insert(arr, 1, "hi!"); show_debug_message(arr); // [1, "hi!", 2, 3]
array_pop(array)➜value 移除数组中的最后一个元素,并将其返回。
var arr = [1, 2, 3]; show_debug_message(array_pop(arr)); // 3 show_debug_message(arr); // [1, 2] array_delete(array, index, count)
array_delete(array, index, count) 删除数组中某一偏移处的元素
var arr = [1, 2, 3, 4]; array_delete(arr, 1, 2); show_debug_message(arr); // [1, 4] array_sort(array, sorttype_or_function)
array_sort(array, sorttype_or_function) 对一个数组进行升序/降序排序(就像ds_list_sort一样)。
var arr = [5, 3, 1, 2, 4]; array_sort(arr, true); show_debug_message(arr); // [1, 2, 3, 4, 5]
或通过提供的 "comparator"函数传递每个元素
var strings = ["plenty", "1", "three", "two"]; array_sort(strings, function(a, b) { return string_length(a) - string_length(b); }); show_debug_message(strings); // [ "1","two","three","plenty" ]
-----------------------------------------------------------------------------------------------------------------------------------
script_execute_ext
记得我们以前通过switch语句来根据某种情况script_execute么?现在不需要了。
var arr = [1, 2, 3, 4]; var test = function() { var r = ""; for (var i = 0; i < argument_count; i++) { if (i > 0) r += ", "; r += string(argument[i]); } show_debug_message(r); } script_execute_ext(test, arr); // `1, 2, 3, 4` - 整个array script_execute_ext(test, arr, 1); // `2, 3, 4` - 从偏移量开始 script_execute_ext(test, arr, 1, 2); // `2, 3` - 偏移量和计数
-----------------------------------------------------------------------------------------------------------------------------------
数据结构检查
增加了四个函数,用于检查ds_list和ds_map项是否为map/list:
ds_list_is_map(id, index) ds_list_is_list(id, index) ds_map_is_map(id, key) ds_map_is_list(id, key)
这可以验证你正在访问的东西(特别是对于json_decode输出)确实是一个map/list
-----------------------------------------------------------------------------------------------------------------------------------
ds_map functions
增加了两个函数用于枚举map的键/值:
ds_map_values_to_array(id,?array) ds_map_keys_to_array(id,?array)
这些对于迭代大型map来说是很方便的,特别是如果你希望在迭代过程中修改它们(这就是ds_map_find_*函数有未定义行为的地方)。
-----------------------------------------------------------------------------------------------------------------------------------
类型检查功能
is_struct, is_method已经被加入,用于检查一个值是否是一个结构或一个绑定的函数,但还有一个额外的功能--is_numeric将检查一个值是否是任何数字类型(real, int32, int64, bool)。
-----------------------------------------------------------------------------------------------------------------------------------
突破性改变
需要注意的几件事:
2d array functions
由于2d数组函数现在已被废弃,它们翻译成如下。
- array_length_1d(arr) ➜ array_length(arr)
- array_height_2d(arr) ➜ array_length(arr)
- array_length_2d(arr, ind) ➜ array_length(arr[ind])
这里的意思是array_height_2d并不关心你的数组是否真的是2D的(里面有子数组),因此在1D数组上使用时会返回意外的值--例如array_height_2d([1, 2, 3])是3。
你可以通过以下方式来解决这个问题
function array_height_2d_fixed(arr) { var n = array_length(arr); if (n == 0) return 0; // 空/不是一个数组 for (var i = 0; i < n; i++) if (is_array(arr[i])) return n; return 1; // 里面没有数组 }
(只有当数组包含子数组时才会返回>1)
但是这仍然会对包含1d数组的1d数组产生误报,因为现在2d数组就是这样。
-----------------------------------------------------------------------------------------------------------------------------------
默认返回值
以前,如果脚本/函数没有返回任何东西,则脚本/函数调用会返回0。
现在它们会返回undefined。
这通常是一个很好的变化,因为GameMaker在很多地方仍然使用数字ID(忘记返回一个值可能会导致你使用一个有效但不相关的结构,索引为0),但可能会打破旧的代码,这些代码只能通过偶然的机会真正起作用。
在2.3.1中,一些内置函数也同样被修改为如果它们不应该返回任何东西,则返回undefined(以前也是0)。
-----------------------------------------------------------------------------------------------------------------------------------
self/other 值
在GameMaker≤8.1时代,写
show_debug_message(self); show_debug_message(other);
将分别显示-1和-2,这在大多数函数中被视为一种特殊情况。
这在GMS1中被改变了,相当于self.id和other.id。
现在这一点又被改变了,self/other现在给你提供了实例 "structs"--所以
hi = "hello!"; show_debug_message(self);
现在将显示 { hi : "hello!" }. 这有一些影响。
- self-struct不等于self.id,所以依赖它的旧代码会被破坏。(在这种情况下,对self的使用最好用self.id代替)。
- 与通过ID引用不同,使用实例结构,即使实例已经通过instance_destroy从房间中移除,你也可以使用实例变量(但仍然可以使用instance_exists检查它是否在房间中)。
-----------------------------------------------------------------------------------------------------------------------------------
Prefix-ops as then-branch
这种
if (condition) ++variable;
这种
if (condition) --variable;
由于各种新的句法结构造成的歧义,不再允许使用,这使得很难判断您的意思是 if (condition)++ <expr> (条件表达式的后增量) 还是 if (condition) ++<expr> (在 then-branch 表达式上预增量)。
如果您想要个人看法,我宁愿禁止将 (variable)++ 等同于 variable++ - 我认为我没有看到在任何项目中有意使用这种构造。
无论如何,这很容易解决。
-----------------------------------------------------------------------------------------------------------------------------------
array[$hex]
由于a[$b]现在用于结构访问器(见上文),试图做array[$A1](以前用Pascal风格的十六进制字头索引的数组访问)将不会像以前那样工作(而是试图从一个叫A1的变量中读取键)。
你会想把它改为array[ $A1](为了清晰起见,有一个空格)或array[0xA1](C语言风格的十六进制字面)。
-----------------------------------------------------------------------------------------------------------------------------------
image_index
以前,image_index被允许溢出image_number,这将使它在绘图时循环(image_index % image_number)。
在2.3版本中,试图分配image_index超过image_number时,会在分配时将其循环回来,这意味着:
sprite_index = spr_3_frames; image_index = 4; show_debug_message(image_index);
将显示1而不是4。
在大多数情况下,这是无害的,并修复了一些与在游戏启动时保存越来越大的索引有关的奇怪现象,但这确实意味着,像
if (image_index >= image_number) { image_index = 0; sprite_index = spr_other_sprite; }
将不再触发,需要进行修改。
-----------------------------------------------------------------------------------------------------------------------------------
buffer_get/set_surface
当导入旧项目到2.3.1时,你会经常看到以下错误。
wrong number of arguments for function buffer_get_surface wrong number of arguments for function buffer_set_surface
这是因为在2.3.1之前,这些函数有如下签名。
buffer_get_surface(buffer, surface, mode, offset, modulo) buffer_set_surface(buffer, surface, mode, offset, modulo)
而现在他们有了以下内容。
buffer_get_surface(buffer, surface, offset) buffer_set_surface(buffer, surface, offset)
有关这方面的更多信息,请见此文。
-----------------------------------------------------------------------------------------------------------------------------------
结论和进一步阅读
请放心,2.3的变化是非常令人兴奋的,并且拓宽了在GML中可以做的事情的视野。最值得注意的是,许多JavaScript代码现在可以很容易地被移植到GML中,正如用户创建的库(如GMLodash)所展示的那样。
关于这里可能没有涵盖的细节,你可以查看
玩得开心!
2021年12月3日
晴
SNOWFALL DEVLOG_03
经历了大概10天的严重睡眠不足,白天大部分时间不是补觉就是打瞌睡。接着生病,不过好在一天就恢复了。
基本上这些天都在做3C,发小给的建议是花大量时间在3C上再往后推,要不然可能会有很多返工,可能还要持续一段时间。
之前的准星和移动处理,基本上和nuclear throne差不多,开始测试的时候我用的手柄(像helldivers那样,没有开启准星),忽略了键鼠操作准星可能导致的问题。在键鼠下,当鼠标(准星)固定在某一个点时,角色会在移动的时候根据瞄准角度(鼠标位置)去自动转身,会有点奇怪,就像车在漂移,这个体验很难受。就改成了helldivers那种逻辑,在非瞄准状态下,射击方向和朝向匹配,这样在键盘操作上,如果不瞄准,就是8方向射击,更复古,也可以在不减少移动速度的情况下向一个固定方向射击;如果在比较安全的距离,利用瞄准来精确射击。
精确射击本来打算给敌人设计弱点区,比如这样
但是top-down 3/4 这个视角很尴尬,比如横版卷轴或者3D TPS、FPS来说,敌人和玩家是站在地上,但在2d top-down 这个视角下,其实敌人和玩家实际上是躺在地上 =_=
后来想如果做多个hitbox,在子弹上再加raycast处理来检测hitbox,后来想到如果关联到动画上可能工作量挺大,有点嫌麻烦,暂时先用瞄准线和敌人的中心点之间的角度来处理精确瞄准。后面还是需要试一下raycast,哪怕只给boss或者比较强的敌人做这个功能,这个如果实现了,战斗会更有意思一些,对于枪法好的人来说。
另外学着用动画曲线做了一个完美换弹的试验,不过可能并不用像战争机器那样麻烦,可能Returnal那种处理更简化一些(虽然是建立在无限子弹的基础上),还没有考虑好,暂时没有扔进工程。
………………………………………………………………………………………………………………………………………………………………………………………………………………………………
在写代码和想功能之外,做美术资源的时候开始比较头疼,由于想做非像素,就没办法使用aseprite,用photoshop在做动画上折磨了大概一天,实在是受不了了,找了一些做动画的插件,看视频感觉也不是很方便,就用去年疫情时候买的优动漫的正版开始做角色粗略动画,但是做到一半……发现动画的帧数还有限制,wtf,只好去买了个破解版的Clip Studio Paint EX(功能全部开放),正版我实在是承受不起,在导出的时候CSP只能导出单帧图像,并不像aseprite那么方便,还要用Free texture packer处理成条状图再回photoshop里加颜色,不过整个流程都比只在PS里做动画心情舒畅多了。(顺便说一下,这个软件千万不要像我这样装繁体中文版,很多地方都看不懂,靠猜……还不如在安装的时候直接选择英文了)
………………………………………………………………………………………………………………………………………………………………………………………………………………………………
大前天在sprite editor里操作的时候,GMS2直接闪退(没有报错弹窗),重启后并没有任何提示信息,我也没当回事就忘了这茬儿。
前天晚上孩子睡觉以后,突然想去改一个功能,然后就出现了资源树里object索引出错的问题,无法新建任何object,只要新建,各种代码报错(注释掉相关的,其他报错,无限),只要删除新建的object,恢复,google搜了很多也没找到相关的情况。
在请教了@流贾君 之后,只能导出所有文件做yymp重开项目,成功救回来了工程。
昨晚在@流贾君的过程中还发现音频组合材质组不能改名了(搜索了一下,在reddit和gms社区也有人发帖说这个问题)………上个月新建工程的时候还可以,我换了上一次的runtime发现也不能改名,暂时只能等更新了,不敢用beta版。
………………………………………………………………………………………………………………………………………………………………………………………………………………………………
有用的连接:
GMS2 BLOG上的文章,不单单可以扩展世界,利用这个方法可以像魂系列那样来进行敌人的刷新处理
EXPANDING WORLDS: BUILDING GAMES WITH INTERCONNECTED LEVELS
由于更新2.3之后都没咋用过,看了这个教程感觉要补习很多东西,新加的constructor和static,要花时间仔细学学用法。
(上面俩视频连接都来自Youtube的SamSpadeGameDev,这个频道很不错)
………………………………………………………………………………………………………………………………………………………………………………………………………………………………
2021年11月22日周一
早上阴沉沉 这会儿大太阳
Highway
译:[GMS2] SEALS OF THE BYGONE中随机生成平台游戏关卡的方法
作者:Logan Foster
译:highway★
游戏体验的多样性是Roguelite游戏的支柱之一。算法生成的关卡,从平台游戏的角度来说,存在很多挑战,对于刚入手GMS的新手来说也比较复杂。比较折中的方式是将预制的关卡区块链接组合在一起,下面是我在Seals of the Bygone中制作随机关卡的方法,用到了GMS2的图层系统。
摘要
从本质上说,我们要为关卡创建一些不同的区块(section)。每个区块包含子区块(subsections),其中包含我们的资产(asset)。通过使用代码来动态切换子区块的开关(on/off),我们就可以随机关卡了。(译注:当然这不如dead cells那种算法加预制的随机方法效果好,不过对于刚入坑这里的朋友来说应该也是种不错的选择,而且在区块中来控制关卡设计,也相对纯算法容易一些,比如做risk of rain类型的游戏,应该是很够用了)
我们没有在代码或外部资源中存储关卡区块信息,而是在Room Editor中创建它们,并存储在图层中,就跟做其他关卡一样。这也是我创建这个系统的主要原因,我想保留现有的设计流程,该流程基于Room Editor。
配置
第1步:创建父区块
首先,我们要为每个主要结构创建空白资产层。区块的数量并没有限制,在下面的示例关卡里,我们创建了3个区块,名为"Section1","Section2","Section3"。这些资产层基本上就相当于结构文件夹,也可以在代码中引用。
第2步:创建子区块
现在,创建子区块的其他空白资产层。这些图层将保存我们构成关卡的tile和object。这里我们将其命名为"Sub1_1","Sub1_2"等。
第3步:子区块的图层
最后,我们将构成关卡的tile、asset和object层也加入到这些子区块中。上图中,子区块"Sub2_1"是打开的,并存在"Collision2_1"和"Tiles2_1"这类图层。这些就是我们构建关卡要用到的层。
现在我们完成了所有设置,你可以切换父组的可见性,以便查看子区块的不同组合。我建议你在完成设计子区块后使用锁定功能,以确保不会意外的编辑它。
代码
我们已经搞定了关卡区块,并存储在不同的层中,现在可以来试试在关卡开始的时候随机选择哪些层了。在Room Start事件中敲下面的代码。
首先,我们定义要用于区块和子区块的前缀。我们将使用它们的名字来找到我们要用的层,请确保这些命名与你在Room Editor中的层相同。然后我们循环查看有多少不同的区块。
现在我们知道有多少区块了,遍历每个区块并查看有多少个子区块。然后选择一个随机的子区块来使用。
在决定使用哪个子区块之后,我们循环并销毁其他所有子区块(在该区块内)。最后我们通过设置层的可见性进行清理,以确保一切正确。如图,这里的图层名称是"硬编码"的,所以你需要将它们更改为你的区块包含的内容。我们可以动态的执行此操作,但这里没什么必要。
结论
完事儿了,很简单的解决方案,可以为你的关卡增添一些随机性。如果你想做一些更强大的东西,可以试试用layer_x和layer_y移动一些东西来扩展这个系统。
如果你有什么问题,可以随时联系我@rologfos
谢谢阅读!
2/23/2019
H
【译文】GAMEMAKER STUDIO系列:简单状态机
作者:Nathan Ranney
翻译:highway★
按照设计,状态机一次只能处于一种状态。 由我们来定义对我们的情况有意义的状态,以及它们之间的关系。 在本文中,我们将使用状态机来控制在任何给定时间可用的玩家操作,允许我们设置角色并定义角色可以执行的操作。
大家好, 今天我想告诉你如何设置一个简单的状态机。 状态机是一种数据结构,顾名思义,它跟踪不同的状态。 例如,我们的游戏可能有三种状态:“游戏运行”,“游戏暂停”和“游戏结束”。我们可能会使用状态机来记住哪一个处于活动状态,并定义如何从一个状态转换到另一个状态(请参阅 上面的图片)。
基础设置
此条目需要比上一个篇文章(绘制精灵)多一些动画,因此在开始之前,你需要将这些动画添加到工程中。 从此链接下载精灵并将其添加到你的项目中。 我已经恰当地命名了文件,因此只要确保精灵的名称与文件名相匹配,就可以将其添加到GMS中。 继续添加所有精灵 - 甚至是敌人的精灵 - 因为我们将在以后的文章中需要这些精灵。 确保每个精灵的原点是(16,32)。
枚举,控制器和持久性
为了设置我们的状态机,我们首先确定哪些状态是可能的,以及我们如何在代码中识别它们。 由于这个例子都是关于角色动作的,所以让我们定义那些动作是什么,并给每个动作一个整数id。 最简单的方法是使用enum(枚举,Enumeration的缩写),它是在自定义变量类型下保存的常量的集合。 如果你熟悉Gamemaker中的Macros(宏),那么枚举就是这样的。 我更喜欢使用枚举,因为它们比宏更容易管理和跟踪。
创建一个脚本并将其命名为enum_init。 添加以下行。 //states enum states { normal, crouch, attack, hit }
请注意,我们不必设置每个条目的值,枚举自动将值0指定为“正常”,然后在每个条目后递增。 我们可以随时获得值
var example = states.attack; show_message(string(example)); //output: 3
实际上,你可以通过为每个条目指定值来覆盖枚举的自动编号,但重要的是要重申枚举是常量。 它们定义后无法更改!
枚举也是全局的,这意味着任何对象都可以访问它们。 这对我们的状态机来说非常完美。
现在我们有了枚举,我们在哪里实例化它? 从非持久对象调用这些变量,比如我们的oPlayer对象,不是最好的主意。 我们想要做的是创建一个持久的控制器对象(它始终存在),以管理许多对象可以访问的枚举和其他数据类型之类的东西。 继续创建一个新对象并将其命名为“con”(译注:建议还是oController这样与其他对象保持相同的命名前缀,在看代码时会比较易读。)。 我喜欢保持我的控制器名称简短,因为它更容易返回。选中新对象上的“持久(Persistent)”框。 最后,将Create事件添加到对象,并添加以下面的代码:
///init 初始化 enum_init();
将con对象放在你的房间里。 由于此对象是持久的,因此除非您明确销毁,否则它将继续存在! 无需在每个房间放置此物体。
Switch cases
既然我们已经在枚举中定义了状态,我们就可以从我们的玩家对象中访问它们了。 打开我们在上一个条目中创建的oPlayer对象,并将以下行添加到create事件中。
attack = false; //states currentState = 0; lastState = 0; //movement xSpeed = 0; ySpeed = 0; lastSprite = sprite;
将End Step事件添加到oPlayer,然后添加一些代码。
xPos = x; yPos = y; x += xSpeed; y += ySpeed; //animation frame_reset();
现在让我们跳到step事件。 我们可以删除我们在之前那篇文章中添加的几乎所有代码,因为大多数代码只是为了展示draw_sprite_ext的不同部分。 查看下面的代码,并确保你的step事件看起来完全相同。
//buttons player_buttons(); //animation frame_counter(); //state switch switch currentState { case states.normal: normal_state(); break; case states.crouch: crouch_state(); break; case states.attack: attack_state(); break; }
如果你之前从未见过switch语句,你可能会想知道到底发生了什么。 我稍后会解释,但首先我们需要创建三个新脚本:normal_state,crouch_state和attack_state。 我喜欢使用不同状态的脚本,因为它使代码更容易阅读。 你可以弹出所需的任何脚本(译注:在GMS2中,在代码中的脚本上按下鼠标中键即可弹出对应的脚本,并链接在当前对象窗口),并在该特定状态下工作。
好吧,所有这一切究竟意味着什么呢? 什么是switch语句以及它是如何工作的? 将switch语句视为if语句的更具体版本。if语句用于布尔值检查,条件满足则执行,switch语句用于根据变量的值执行代码。 看看下面的代码块。
//if statement if(currentState == states.normal) { normal_state(); }else if(currentState == states.crouch) { crouch_state(); } //switch statement switch currentState { case states.normal: normal_state(); break; case states.crouch: crouch_state(); break; }
这两段代码在功能上都是相同的。 它们都将根据currentState变量的当前值运行我们想要的脚本,但switch语句要清晰得多。 当我们添加状态时,使用if语句变得难以管理。 switch语句更容易管理。
最后,我们需要在player_buttons脚本中添加一个新的按钮变量。 打开该脚本并添加此行:
attack = keyboard_check_pressed(ord("Z"));
状态机
我们已经定义了一个可能状态的枚举,以及变量currentState来跟踪哪个状态是活动的。 现在我们知道了switch语句的工作原理,我们可以创建在每个状态下执行的代码,以及在它们之间进行转换的规则。 switch语句可以很容易地显示我们的状态机是什么以及它正在做什么。 如果我们的currentState变量等于语句中的一个case,则执行与该case相关的代码。 由于我们为每个状态创建了脚本,因此请继续打开normal_state脚本并添加以下代码
//移动 if(left) { xSpeed = -2; }else if(right) { xSpeed = 2; }else { xSpeed = 0; } //切换到下蹲状态 if(down) { currentState = states.crouch; } //切换到攻击状态 if(attack) { currentState = states.attack; }
这段代码非常简单。 对我来说,正常状态意味着角色的默认状态。 他们没有执行任何特殊操作,例如攻击或使用道具,玩家可以完全控制角色。 在这里,我们有左右移动,并转换到蹲和攻击状态。 如果你现在运行游戏,你将无法看到我们的状态机的全部效果。 如果你按下或Z,你将改变状态,不再能够移动。 接下来让我们定义蹲状态。 打开crouch_state脚本并添加以下代码:
xSpeed = 0; if(!down) { currentState = states.normal; }
蹲下时(按住向下箭头键)我们停止玩家的水平移动(xSpeed = 0)。 如果他们释放向下键,我们将返回正常状态。 这将是一个在蹲下时添加不同动作的好地方,比如爬行或者可能是蹲下的攻击。
打开我们创建的最后一个状态脚本,attack_state,并添加以下代码:
xSpeed = 0; if(frame > sprite_get_number(sprite) - 1) { currentState = states.normal; }
我们再次将水平速度归零,并且当动画结束时我们将玩家状态设置回正常。 但是......我们还没有设置我们的动画,是吗? 动画控制是状态机和switch的另一个重要用途! 创建一个新脚本并将其命名为animation_control。 添加以下代码:
xScale = approach(xScale,1,0.03); yScale = approach(yScale,1,0.03); //动画控制 switch currentState { case states.normal: if(left) { facing = -1; }else if(right) { facing = 1; } if(left || right) { sprite = sprPlayer_Run; }else { sprite = sprPlayer_Idle; } break; case states.crouch: sprite = sprPlayer_Crouch; break; case states.attack: sprite = sprPlayer_Attack; break; } //如果精灵更改,则将帧重置为0 if(lastSprite != sprite) { lastSprite = sprite; frame = 0; }
通过使用另一个switch语句,我们可以轻松控制播放器动画。 请注意,我们可以在switch case中使用if语句! 我们没有将动画控制与我们创建的初始switch语句组合在一起的原因有几个。 首先,我们希望我们的动画在所有代码的最后发生。 动画是之前发生的一切的结果! 其次,它让代码更好读。 上面代码底部的最后一个表达式会在精灵更改时将帧重置为0。 这可以防止在更改精灵时动画在错误的帧上启动。
请注意,我们将xScale和yScale代码移动到animation_control的顶部。 这对以后很重要。
在oPlayer对象中打开end step事件,并将以下行添加到代码的底部。 这将确保它在其他一切之后发生。
animation_control();
来吧,运行游戏。 你应该有一个能够左右奔跑,空闲,蹲和攻击的角色了! 我们能够根据当前状态区分角色的行为。 除了管理优势之外,设置状态机还可以更轻松地跟踪错误,添加新行为以及跟踪对象的整体结构。 我经常使用状态机和switch语句来控制比如要显示的菜单屏幕,当前的游戏模式以及定义给敌人的AI类型等内容。
谢谢阅读! 在Twitter上关注我,并在我的网站上关注更多与游戏开发相关的内容。
【译文】GAMEMAKER STUDIO系列:构建更好的动画系统
作者:Nathan Ranney
翻译:highway★
在开发Kerfuffle(译注:游戏挂了,过于追求视觉效果、没钱、再加上一些其他问题,他们现在在做Knight Club)时,我需要一个动画系统,允许我在游戏中保持任何单独的动画帧(译注:格斗游戏/动作游戏为了提升打击感,会采用帧冻结的技术),而无需手动添加或删除精灵帧。 我还需要能够根据当前动画帧触发某些动作。 使用此设置,我可以创建hitbox,播放声音或更改状态,同时完全控制屏幕上绘制的所有内容。
变量
这些是与动画系统相关的重要变量。 如果后面你感到困惑,请回头再仔细看看。
frameSpeed
The speed in FPS that the game counts through the frames to be displayed. Default is 1.游戏通过要显示的帧计算的FPS速度。 默认值为1。
frameCounter
Increases every frame by the frameSpeed.
每帧按frameSpeed递增
currentFrame
The frame of the sprite currently being drawn on the screen.
当前正在屏幕上绘制的精灵帧
frameData
Current list of frame data the game is reading from, based on the animation that needs to play. Idle, run, attack, etc.
游戏正在读取的当前帧数据列表,基于需要播放的动画。 如空闲,奔跑,攻击等
frameDuration
Total number of in game frames to display the current sprite frame.
显示当前精灵帧的游戏帧总数。
maxFrames
The total number of frames in any given sprite.
任何给定精灵中的帧总数。
animSprite
The actual name of the sprite resource in GameMaker. sprMomo_Idle, for example.
GameMaker中精灵资源的实际名称。 例如,sprMomo_Idle。
脚本
后面我们要用到的脚本。
frame_reset();
//将frameCounter和currentFrame重置为0 frameCounter = 0; currentFrame = 0;
animation_set();
该脚本接受两个参数。 首先,frameData(相关的帧数据列表)和第二个是animSprite(你想要绘制的精灵资源)
//animation_set ( argument0, argument1 ); frameData = argument0; animSprite = argument1;
帧数据
每个动画都需要一个帧数据列表。 这是一个列表,其中包含每帧动画播放的游戏帧数量。 每个数据列表都使用以下命名约定。 frameDataIdle,frameDataRun,frameDataDash等。
(译注:如果你初次接触这些并对这些内容感兴趣,可以扩展阅读一下,google搜一下街霸系列的frame data,会有很多更详细的资料。btw indienova如何插入表格呢?)
Momo(我们游戏中的一个角色)空闲动画的帧数据
请注意,所有列表和值都以0开头。因此,即使此动画有12帧,列表中的最大数字也是11。这包括你要显示的帧! 如果你希望它在游戏中显示5帧,则列表中的值应为4。
** GMS特定说明**
确保在不再使用时手动删除列表! 否则你可能会遇到内存泄漏!
帧计数器
现在我们有一个帧数据列表,我们需要实际根据该数据设置动画。 我们需要做的第一件事是弄清楚maxFrame是什么。
maxFrames = sprite_get_number( animSprite ) - 1;
然后,如果您的currentFrame恰好大于或等于最大帧,并当frameCounter大于或等于精灵帧应出现在屏幕上的最大帧数,则重置为第一帧。
if ( currentFrame >= maxFrames - 1 && frameCounter == frameDuration ) { frame_reset(); }
现在frameCounter可以完成它的工作。 它计算应该显示精灵的当前帧的帧数,然后一旦达到该最大值,将当前帧切到精灵的下一帧,并重置为0以再次开始计数。
frameCounter += frameSpeed; frameDuration = ds_list_find_value ( frameData, currentFrame ); if ( frameCounter == frameDuration ) { currentFrame ++; frameCounter = 0; }
注意:
使用maxFrames也是结束动画和更改为新动画或状态的好方法! 在整个攻击动画一直播放之后,我使用maxFrames从攻击状态切换回正常状态。
** GMS特定说明**
sprite_get_number是一个内置的GMS函数,它返回精灵中的帧数。 此函数返回确切的帧数,并且不会从0开始计数! 所以如果你有一个5帧的精灵,这将返回5! 这就是为什么在检查maxFrames时,我们这样做,同时从其值中减去1。
切换精灵
Kerfuffle中的所有内容都运行在一个相当简单的状态机上。 根据角色所处的状态,动画会发生变化。
//存储当前的动画精灵,以便我们稍后检查 currentAnim = animSprite; switch ( state ) { case normal: //如果玩家向左或向右,则改为跑步精灵 if ( left || right ) { animation_set ( frameDataRun, runSprite ); //如果玩家没有按任何按钮,则更改为空闲精灵 } else { animation_set ( frameDataIdle, idleSprite ); } break; case dash: //如果玩家状态为前冲,改为前冲精灵 animation_set ( frameDataDash, dashSprite ); break; } //针对上一个动画检查当前动画。 //如果这些动画不相同,请将frameCounter和currentFrame重置为0。 if(lastAnim != currentAnim) { frame_reset(); lastAnim = currentAnim; }
** GMS特定说明**
尽可能使用宏或枚举而不是字符串。 我曾经使用字符串作为玩家状态(即:“normal”而不是normal),这可不是个好主意。 字符串处理速度较慢,如果您输入错字,GMS可不会提醒你! 你的代码将失效!
有关宏的更多信息,请查看YellowAfterLife的这篇文章。
https://yal.cc/gamemaker-on-global-variables/
投入使用
现在我们已经设置完看上面那些玩意儿,我们要开始使用它们。 由于我们绕过了像image_speed和sprite_index这样的GMS内置函数,我们需要自己绘制精灵。 这真的很容易! 我们只需要使用draw_sprite_ext!
//draw事件 draw_sprite_ext ( animSprite, currentFrame, x, y, 1, 1, 0, c_white, 1 );
就是这样了! 很简单吧? 如果您对如何改进此问题有任何疑问或意见,请告诉我们。我会尽快回复。如果你还有什么其他想了解的内容,请告诉我~
【译文】2D平台游戏操作:如何避免手感发软和手感僵硬
作者:Yoann Pignole(游戏/关卡设计师@Ubisoft)
翻译:highway★
(译注:文中涉及到代码均为伪代码,图片上的英文没几个,如果有疑问,还请自行对照词典,见谅)
矛盾的是,这两种感觉背后的问题往往是同样的:对玩家输入反应不灵敏,不精准。
我的目标是帮助新手游戏设计师(也许是更有经验的游戏设计师)避免这种控制感受。但请记住,反应和精确控制不是必须的,也不适用于所有游戏。
因此,在澄清这一点之后,让我们谈谈在我看来可能造成玩家会有这种感觉的常见原因:
动画优先
第一代波斯王子,另一个世界和黑暗灵魂(译注:如果你还不了解这三款游戏,最好先去搜一下看看,然后再回头来看下面的内容。)有什么共同之处?答案:在玩家输入之前优先考虑玩家动画。如果这3款精彩游戏以这种约束构建他们的游戏玩法,也许这就不是你想要的“反应性”游戏。
详细的说,当动画具有“优先级”时,玩家将无法在另一个动画仍在播放时执行某些操作:
如果你希望避免这一点,非常简单:尽管它会削减你的角色动画,但要完全优先考虑玩家的输入。
对经验丰富的设计师来说,虽然这个问题很常见,但也千万别忘了!
地面摩擦力低
我所说的摩擦力是一种力,当玩家在相反的方向松开摇杆时,它会在施加在角色速度上,直到玩家停下来。
是的,有一个比较低的地面摩擦非常酷,特别是当你配上一个漂亮的对应动画时!但是当你的玩家在全速移动时需要0.5秒才能完全停下时,这时间就太长了!
如果不是在冰平台上或者角色踩着滑板之类的东西,要小心地面上的摩擦力太低,别犹豫,增加它吧。
改变方向时没有反应
如果你使用经典的系统,来控制你的玩家在地面上的移动,比如:移动加速度,最大速度和摩擦力来帮助停下玩家,也许你注意到当你突然改变方向时会出现你没有想到的结果:玩家没有反应过来其实他们还需要一点儿时间才能向另一侧走。
让我们用2D平台游戏来解释。每圈的速度计算是:
speed = speed + acceleration (当然有最大速度限制!)
如果玩家按下“右键”,则加速度为正,速度增加为正值,角色向右移动。
现在,如果玩家突然按下“左键”,则加速度现在为负,并且速度从正到负逐渐减小 ( 速度从正变为负需要一定的时间 ),玩家逐渐减速到最后向左移动。看下图:
那么,现在,解决方案是什么?实际上它很简单:你只需要使用“反应百分比”。
当玩家以与实际速度相反的方向输入输入时,我称之为“反应性百分比”是加速度的额外百分比:
If 输入方向与实际速度方向相反 speed = speed + acceleration + acceleration * reactivityPercent Else speed = speed + acceleration
看下图:
该解决方案也可以在3D环境中使用,稍作修改,因为方向可以“或多或少”彼此相反。这个想法是对这个加速百分比应用一个比率,与“相反的量”有关:
//输入和实际方向之间的反对率 oppositionPercent = opposition ratio between input and actual direction (0是相同的方向,1完全相反) speed = speed + acceleration + acceleration * reactivityPercent *oppositionPercent
模拟摇杆上的非模拟速度
有些游戏使用2个速度值,通常是“行走”和“跑步”。
如果这个解决方案在8位和16位主机上的手柄上效果很明显,因为当时的手柄是定向性交叉控制运动(默认情况下,玩家要走路,按下一个按键,角色移动)(译注:最好的理解是PS的手柄断开的十字键……),但在现在普遍的带模拟摇杆的手柄上就有点不合逻辑了:因为当你的拇指控制摇杆是渐进的话,你也期望速度是渐进的。
只有这两个值与模拟摇杆一起使用并不总是会产生“僵硬”的感觉,但是,如果这些值彼此相距太远,玩家将会感觉到这个“阶段”在模拟摇杆的中段。
(译注:如果你常玩各种3D游戏,就会感受到很多中小型公司制作的质量一般的游戏的生涩手感,你控制的主角总是走到你不想让他去的地方……)
模拟速度实现起来非常简单,因为大多数商业游戏引擎针对每个轴都有-1到1的值。因此,您可以确定当前最大速度设置“最大”的最大速度(当你的摇杆全部向右/向左时的最大速度):
currentMaximumSpeed = MaximumSpeed * AnalogStickRatio
当然最好以同样的方式调节加速度。
重力低
太低的重力值可以明确地给你这种跛行的控制感受,就像你的角色是羽毛或是在月球上。(译注:很多这样令人尴尬的游戏……)
你可以选择这么做,但有时候,设计师会降低重力以使平台更容易挑战(或者保持地球默认值为9.81m / s,不适合其物体尺寸)。这是一种可能性,但请记住,你有失去“体重感”的风险,这对于在挑战平台方面取得成功是非常重要的。
空中控制(空中加速)未调整
空中控制(在空中水平方向上移动玩家的能力)在现实世界根本不可能。但是在视频游戏中,现实行为并不总是有趣的,对于有跳跃挑战的平台游戏,你可能需要一些空中控制。(译注:当然,有些复古硬核,比较早期的FC平台的恶魔城,就可以让你体会在空中无法控制角色的感受,呃,游戏体验因人而异吧~)
如果必须仔细调整地面上的加速度参数,那么对于空中加速度来说更是如此(因为玩家正在下落并且没有全世界的所有时间做出反应):
- 太低的空中加速度不会让玩家有机会在空中纠正他的轨迹,他会有一种“沉重”的感觉。
- 太快的空中加速度对于玩家来说是很难度量和把握的
而且,很明显,低空中加速度会使高重力值的“重量”感倍增,而较快的空中加速度会让低重力的“浮动”感倍增。
因此,应该考虑把重力和空中加速度一起进行调整。
但是,要注意不要总是用较高的空中控制来“补偿”高重力,反之亦然,有时最好保持2个值之间的一致性。不要忘记“极端”的值可以给游戏带来强大的辨识度:
非模拟跳跃
即使在现代和AAA游戏中,非模拟跳跃解决方案,即只有一个没有任何调制可能性的跳跃高度,也是经常出现的。首先,让我们来弄清楚,我称之为模拟跳跃机制和非模拟跳跃机制之间的区别是什么:
在有些游戏中,比如刺客信条或者GTA系列,非模拟跳跃并不是什么问题,因为这些游戏并不专注于跳跃挑战,并且游戏本身希望保持一种真实感。然而,在那些“以跳跃为核心”的游戏(比如:Super meat boy、Mario、Sonic还有最近的Celeste)中,如果玩家能够自己通过操作来控制跳跃,尤其是在空中,那就会帮助他们成功完成一些特定的挑战(比如在尖刺的天花板下跳跃)。此外,它有助于提升玩家对反应控制的感受。
实现模拟机制可能有点棘手。我作为设计师的解决方案直接受到Sonic中使用的解决方案的启发(感谢Sonic Physics指南中的“跳跃速度”部分):假设你使用冲力速度跳跃(让角色逐步退回,同时采取行动重力),想法是定义一个“削减跳跃速度限制”,并且,如果当玩家释放输入时垂直速度高于此限制,则将垂直速度设置为等于限制。如果速度低于此值,则表示角色足够接近后退,并且不必“直接”降低跳跃速度。
当然,你需要一些尝试来找到“削减跳跃速度限制”的参数值。由于这个值与你的重力有关,对我来说,“gravity / 10”的值效果很好:
cutJumpSpeedLimit = gravity / 10 If isJumping and jumpInputReleased and verticalSpeed > cutjumpSpeed verticalSpeed = cutJumpSpeed
缺乏跳跃输入容差
当然,你希望你的角色只有在他站在地上时才能够跳跃。但如果你严格使用这种情况,你可能会遇到一些缺乏反应性的感受。实际上,如果玩家在角色接触地面之前要求跳几帧(并且通常是真的,因为人脑往往会预期过多的动作),呼叫将不会被考虑(译注:有些手残玩家的按键反应速度较慢,这种情况也要考虑进去,否则,则会埋怨游戏开发者。)。同样地,如果玩家想要在角色到达平台末端时跳跃,那么当角色刚刚开始下落时,他可能反应太晚几帧(译注:国外开发者所说的Coyote Time出场了 - 土狼时间)。请看下面:
玩家的感知可以让他认为他能够跳跃,即使从技术上来说实际并不能跳。
为了避免这种看法,解决方案是添加“容差”,这意味着:
- 当玩家给出输入并且角色足够接近地面时,会记录跳跃输入。所以,当角色触地时,他会再次跳起来
- 当角色从平台上掉下来时,如果玩家给出输入,在空中有个延迟时间,让玩家可以在空中跳跃:
跳跃时没有能力
当你按下按键在屏幕上立即出现结果时,会出现“高反应性”的感觉。是的,看起来很明显但是,在玩Skylanders Swap Force时,我很惊讶在我跳跃时无法攻击:没有动画或声音反馈,角色在空中时对攻击按钮没有响应。
在平台游戏中,跳跃是你的主要能力和移动方式,你会经常使用它。因此,在跳跃时禁止任何其他能力必须经过仔细考虑。特别是如果在行动阶段需要这种能力(比如基于混战的游戏中的攻击能力)!(译注:跳起来不能攻击的,没有其他行为的话,那可真是无聊透了……想想,人家Downwell,能跳能踩能用鞋开枪呢~ 同样是游戏,差距咋恁么大呢~)
如果你真的想要出于任何好的理由禁止空中动作,那么就用一些声音,特效或动画反馈来弥补吧,总比干巴巴的跳要好。(译注:想想Metroid,跳起来变球型翻滚,又省了动画制作成本,还可以攻击敌人,还有声音,后来还能升级能力,多段跳多段攻击还带特效带声音,用乔杉话儿说:那不得起飞喽啊~)
圆形平台边缘(光滑边缘)
我做的一些游戏,所有平台都有圆边。有很多理由这样做,但一个常见的原因是避免角色“胶囊”碰撞体卡在直角边缘:
(译注:胶囊是unity3d的一种碰撞体,如果你玩儿GMS,那就略过吧~)
许多游戏引擎,如Unity 3D,使用这种“胶囊”碰撞体通过物理引擎来处理碰撞。因此,使用这种简单快速的解决方案来避免“卡住”问题而不是处理直角边缘非常诱人。
实际上,它不一定是一个糟糕的解决方案:如果你的游戏不是基于跳跃挑战而且不需要高精度移动,那么它可能没问题。但是,如果你想做像Mario那样的跳跃,你应该避免圆形边缘:玩家到达边缘的平台感觉非常令人沮丧,感觉他碰到了平台地面,最后滑进了由于几乎看不见的圆形边缘……
如果您对编码感兴趣,我在“爱好者编码器”系列中写了一篇关于在Unity 3D中编写自定义2D平台控制器的方法的文章,特别是为了避免这种问题。
视觉和声音反馈
是的,反馈非常非常重要。是的,有时甚至在原型过程中。
我意识到平台游戏中的反馈对于个人项目的重要性:即使应用了上述所有其他“规则”,我仍然发现游戏手感不够好。我试图调整变量,修改移动系统等......没有任何改变。最后我厌倦了搜索并决定开始编写一个声音管理器来“放松”一下自己,我加入测试它的第一个声音就是跳跃声。我发现自己的角色更具反应性,我感到很惊讶!我决定继续这种方式并实现与角色速度相关的运行动画速度:游戏似乎再次获得更多反应!
(译注:著名表演艺术家李雪健大爷在画外说:“没声音~ 再好的戏也出不来!”)
这个小故事只是说,有时候,即使你知道动画,声音或FX会在开发的后期出现,大脑也需要它们帮助它“实现”一些物理概念,如速度,移动或重力。而且因为平台游戏都是关于特定物理的理解,我认为在尝试调整移动变量之前尽早实施占位图反馈非常重要。
(译注:Gun Point的开发者Tom Francis也在博客中说过,觉得你的原型没意思?不好玩?加上一些动画、音效再看看,别那么早放弃它。这里并不是让你去花大量的时间去制作美术资产,只是给原型稍微抛抛光,提提色,花不了太多时间,但是很大的可能会给你带来更好的效果,而且有时候是意外的灵感。对你完成原型也有很大的帮助,是种视觉听觉上的鼓舞。)
结论
当然,这个列表并非详尽无遗,我真的很想知道其他人对平台游戏操作的观点。
这个列表也是主观的,这就是为什么我在文中写了很多“我的看法”,在我看来“和”大部分时间“:游戏设计中没有什么是一成不变的。游戏设计师的工作并不是真正的“理论”,这就是为什么写这篇文章非常棘手。在我看来(再次!),游戏设计师的工作中心首先是原型创意,测试它们,原型,测试等,直到他最终得到“有趣”的东西。
最后,我的游戏设计黄金法则:就是没有黄金法则!
最后,感谢一些非常有趣的文章,这些文章激励我写下这篇文章:
(译注:略长,比之前那篇操作缓冲的讲的更多。希望能帮到同样在做平台游戏的朋友)
译文:GAMEMAKER STUDIO系列:Shadow
作者:Case Portman@thunderhorse studio
翻译:highway★
嘿,GMS苦逼们,我是Case(独立游戏Flynn: Son of Crimson的程序员),今天分享个东西,说说如何在Gamemaker Studio中给2D平台游戏中给角色增加影子。在看本教程之前,你应该对GML有些基本的了解,如果你刚刚接触GML,可能看起来会有点儿吃力。(译注:如果你还在用D&D,赶紧扔掉它去学GML吧,D&D那玩意儿除了外行没人用的,就算你用的很爽,后面想找相关教程都找不到)
下面我会介绍两种不同的风格,斑点影子和动态影子。注意:这两种方法都不适用于斜坡,我可能会在稍后再解决这个问题。
准备开始:
首先你需要一个精灵/对象来应用影子,这个例子中我会使用我们游戏的主角Flynn。接下来,你将要设置你的实体对象(你的玩家碰撞的墙/地板),如果你才接触GMS2还不会做的话,我强烈推荐Shaun Spalding的教程。不多说了,赶紧去订阅他的Youtube频道吧。
斑点阴影
阴影精灵图
我们将需要一个额外的精灵用来做影子,所以打开精灵编辑器并绘制出你想要的形状,随便你怎么弄,只要它看起来跟下图差不多就行:
精灵的原点直接设置在影子的中间,然后将其重命名为“spr_blob_shadow”(随便你叫什么)。
代码
OK,现在我们要先写一些代码来检查玩家离地面的距离,不管它的高度如何,影子总是在它应该出现的位置。
打开你想要添加影子的对象然后创建一个Draw事件。我们现在需要声明两个临时变量max_length和solid_object,它们将检查影子离我们设置的对象有多远。
max_length:离地面有多远,直到阴影消失。
Solid_object:用于地面碰撞的对象(例如obj_solid)。
接下来我们写一个'for循环',它不断检查玩家是否在地面范围内(例如64像素),否则我们就会退出循环。使用函数collision_point,并使用临时变量'ly'来找到我们下方的lengthdir_y(270 - 垂直向下)。
(此方法也可用于命中扫描,我将在以后的教程中介绍)
译注:作者已经很久没更新教程了~ 哈哈哈
现在我们已经完成了所有计算,现在是时候把它应用到我们之前创建的斑点影子精灵上了。我们声明一个名为shadow_size的新变量。然后阴影需要通过从前面读取变量' i '来检查是否要绘制,如果不满足条件,则停止绘制阴影。
然后我们绘制影子精灵,它根据上面的计算相应调整自身大小,最后别忘了绘制player本身哦~ 加上draw_self(),把你的玩家精灵绘制在影子上方。
(译注:如果你对draw事件还不是很了解的话,强烈建议去认真看一下F1~)
好了,搞定~
动态阴影
我做动态阴影的方法使用了一些可能跟你的游戏中不同的变量,但为了简单起见,我们假设当你转身时玩家的image_xscale会翻转(左边是-1,右边是1)。实际上,我们正在做的是绘制我们的玩家对象的翻转和“压扁”版本,它模仿每一个动作并粘在地上。
(译注:嗯?你突然有一个想法? 嗯,没错,看完了这部分,你也可以做点水在你的游戏里,然后在水中加上倒影~~当然,还有镜子喽~)
所以,从最后一部分开始,我们只需要做一些小改动,首先我们要从spr_sprite_ext的第一个参数中删除 spr_blob_shadow',并用sprite_index替换它,因为这将跟踪你的sprite在对象中使用。
保持在draw_sprite_ext(译注:这个函数如果你还没怎么用到的话,去看看,超好用,比如你想做武器系统啊,换装系统什么的,配上自定义变量非常好玩儿的~)内部,我们需要将shadow_size乘以image_xscale参数顺序内的image_xscale,让影子与玩家一起翻转,然后在image_yscale参数上我们给它-shadow_size,将阴影翻转过来,再除以4以匹配大小(译注:就是压扁它)。
我们写完的代码要看起来像下面这样:
下面我画圈儿的部分是您可以根据自己的喜好改变阴影的方法,但这些值对我的项目来说效果很好:
OK,完事儿了,动态阴影搞定!
gl
hf
译文:GAMEMAKER STUDIO系列:Timer
依旧面向新手
译注:非完全翻译,有些片汤儿话就不翻了,只翻重点 :p ,我对部分代码进行了补充,加上了对应事件位置和注释,方便gml初学者理解操作,我在读原文的过程中有些地方比较困惑,自己适当修改了一下文章的内容。如果有错误出现,麻烦指正。感谢!
作者:Nathan Ranney
翻译:highway★
如果你看过我之前的文章,应该会注意到我用了很多自定义的计时器,而不是GMS内置的alarm系统。我这么做的原因是,一呢~ GMS的alarm数量上有限制,二呢~ 并不是很直观,有时候写着写着我就记不住alarm[0]里面写过什么,当东西一多,就容易乱套。
(译注:当你的项目越来越大越复杂,可能会用到很多timer,这时候一个个打开alarm去查看很容易让人……怎么说?懵逼? 或许更严重,尤其是你的显示器不够多不够大,想想以前GMS1的IDE,打开无数个窗口……wtf,如果你用自定义的timer,一个窗口就全看的到,很直观~ 当然,最好灵活的使用region,可以让代码更加整洁)
译注:下面的示例,请确保游戏以60fps运行,如果你对GMS2还不熟悉,可以在屏幕右侧Resource最下方找到Option选项,点击Main,在弹出窗口中可以看到Game frames per second。
概述和设置
那究竟什么是Timer(计时器)呢?Timer是一种机制,允许您向上或向下计数到某个值,然后在达到该值时触发某些内容。例如,假设你的角色被毒性伤害攻击到了。毒性伤害通常是随着时间的推移,每隔一段时间就会造成少量伤害。想象一下你的游戏以每秒60帧的速度运行,你决定毒药伤害应该是1点伤害。你的角色有100点血量,你的代码看起来像这样:
示例代码:
//Create event poisonDamage = 1; //Step event if(poisoned){ hp -= poisonDamage; }
这段代码里没有Timer或任何其他限制,它每秒以60fps(游戏每秒60帧)的速度将你的hp降低1。这意味着......你的角色有100点血,在1.6秒内死掉!oh,shit,这毒也忒tm狠了吧?你可不想这样,对吧?你真正想要做的是每隔几帧对你的毒害造成伤害,或者每隔几秒。那么代码应该看起来像下面这样:
示例代码:
//Create event poisonDamage = 1; poisonTick = 60;//补充,原文并未初始化 //Step event if(poisoned){ poisonTick --; if(poisonTick <= 0){ hp -= poisonDamage; poisonTick = 60;//重置Timer } }
译注:请确保option里游戏是以60fps运行
这里,我使用一个新变量poisonTick来确定何时应用毒害。所以现在,之前要1.6秒,现在需要60秒。 poisonTick每帧减少1,一旦poisonTick达到0,玩家hp将减少poisonDamage值,并且poisonTick将重置为最大值60。这需要比使用alarm稍微复杂一点点,但更好用。
要以这种方式使用Timer,您至少需要一个变量,该变量存储你正在计算的任何内容。在上面的例子中是poisonTick。我强烈建议你在重置计数变量时使用第二个变量。这会更方便管理(译注:当你自己的小项目进行了一段时间,重新打开并查阅代码时候,如果你看到一堆数字,可能早已经忘了当时写下它的具体含义了,所以能用变量就用变量,变量名可读性越高越好,如果你不是solo开发,你可不想你的战友碰到一个变量就问你,“哎,这是啥意思?”)。下面的例子会仔细说一下。
再来一个例子
使用手动Timer的另一个好处是可以在多种情况下使用相同的Timer变量。我经常在游戏中使用一个名为actionDur(动作持续时间)的变量来确定任何一个角色动作可以执行多长时间。假设一个角色有两个动作,攻击和前冲,两者都是依赖于Timer的。在Timer持续时间内,角色被锁定在某一动作中。你也可以使用两个计时器来管理它,比如attackDur和dashDur。或者你可以使用单个计时器,并在两个地方使用它。咱们看看下面的例子。
复用Timer
//Create event actionDurMax = 60;//这里没有像上面的例子中继续使用数字了,注意变量名 actionDur = actionDurMax; //Step event if(attack){ actionDur --; if(actionDur <= 0){ actionDur = actionDurMax;//重置Timer attack = false; } } if(dash){ actionDur --; if(actionDur <= 0){ actionDur = actionDurMax;//重置Timer dash = false; } }
如您所见,两个动作都能够使用相同的计时器变量。请注意,如果您在多个位置使用相同的计时器变量,则每次使用计时器必须完全独立于其他用途。因此,再次使用上面的示例,我必须确保攻击和前冲不能同时为true。如果两个变量同时都是true,那么actionDur会以2倍速倒计时。我强烈建议为你的角色设置状态机,这会让你更方便的管理何时运行的代码。
当然你也可以不用倒计时(正数/倒数都无所谓啦~),稍微修改一下就好。无论您选择正数还是倒数(这里数读三声),这都是个人偏好。
正数Timer
//Create event actionDur = 0; actionDurMax = 60; //Step event if(attack){ actionDur ++; if(actionDur >= actionDurMax){ actionDur = 0;//重置Timer attack = false; } }
译注:如果你有点儿困惑,打开手机时钟,一个是计时器,一个是秒表。对,就是它们。
那么,如果您想为多个操作使用单个Timer,但这些操作持续时间有所不同那会怎么样呢?最简单的方法是,如果你使用的是我一直在讨论的相同类型的设置,那就是在动作发生时定义计时器的起点。再次使用攻击操作,让我们看看它是如何工作的。
单个Timer - 但是持续时间不同
//Create event actionDur = 0; actionDurMax = 60; //Step event if(attack_button) { attack = true; actionDur = 30;//这里作者还是继续使用数字了,建议不要这样做~ } if(attack) { actionDur ++; if(actionDur >= actionDurMax) { actionDur = 0; attack = false; } }
在这个例子中,我们不得不使用一个新的代码块。我们添加了attack_button变量,你可以想象这是一个玩家在游戏中推动攻击按钮。当按下该按钮时,攻击变为真,并且actionDur设置为30.默认情况下,actionDur设置为0,当按下我们的虚拟攻击按钮时,我们会覆盖它。但是,当攻击设置为true时,actionDur仍在累加,然后判断是否达到actionDurMax。只有这一次,它不是从0开始,而是从30开始。这实际上减少了我们的攻击行为的持续时间。下面我们再弄的稍微复杂一点儿,我们加上第二个动作
持续时间不同的Timer - 多个动作
//Create event actionDur = 0; actionDurMax = 60; //Step event if(attack_button){ attack = true; actionDur = 30;//这里作者还是继续使用数字了,建议不要这样做~ } if(attack){ actionDur ++; if(actionDur >= actionDurMax){ actionDur = 0; attack = false; } } if(dash_button){ dash= true; actionDur = 10;//这里作者还是继续使用数字了,建议不要这样做~ } if(dash){ actionDur ++; if(actionDur >= actionDurMax){ actionDur = 0; dash = false; } }
译注:这里就不详细解释代码含义了,看到这儿的话你肯定已经了解这些东西到底在干嘛了~ 不是么?
关于自定义Timer,虽然稍微要比Alarm用起来要稍微花些时间来设置,但是这带给我们的好处却更灵活更方便管理自己的代码~ 希望这些内容对你有帮助!感谢你的阅读。
译文:Gamemaker Studio系列:2D平台游戏的输入缓冲
作者:Case Portman(@Studio Thunderhorse, 独立游戏 Flynn: Son of Crimson的开发者)
翻译:highway★
“”我按了跳!!
我TM按了跳键,我C%^&#@%#@&*,
你TM倒是跳啊!!!,我%#^@&!"
耳熟么?我注意到很多2D平台游戏手感很好......但是也有很多游戏的手感挺糟的。
2D平台中对手感比较重要的两个东西,一个是“输入缓冲”(input buffering),还有一个是它的哥们儿“土狼时间 ”(coyote time)。
土狼时间是什么呢?就是当玩家走出平台边缘,跳键按得太晚,但是跳跃仍然有效。这是2D平台游戏开发的一个术语。(译注:当然中文搜索基本上是搜不到的,如果你感兴趣,可以google一下,gamasutra上有一些关于跳跃的开发技巧文章会有更详细的解释。)
下面,咱们说说到底什么是输入缓冲。
输入缓冲(Input Buffering)
这个术语用于给输入一个指令(在我们的例子中,按一个按钮)以保留在内存中,直到它能够执行这个指令。最好的例子是在很短的时间内,让玩家可以按下跳跃按钮,一旦他们落地,角色就会跳跃。
让我们通过使用简单的2D平台游戏实现一下上面提到的东西。如果你已经实现了你玩家跳跃代码,那么你很可能是这么做的:“如果玩家在地上 -> 那就可以跳 -> 跳”,这是一个完美的开始,我们现在要做的就是简单的洗洗牌~
//Player跳跃代码
if place_meeting(x, y+1, objSolid) //检测player是否在地上,注:当然你也可以用脚本 { if keyboard_check_pressed(vk_space) //按下空格键 vspeed = -5; //跳起 }
译注:教程这里用vspeed只是为了省事儿,建议自定义速度变量
我们首先要在什么都没开始之前声明两个变量,buffer_counter和buffer_max,后面我可以对其进行调整。
在create事件中加上:
buffer_counter = 0 // buffer_max = 10 //
接下来我们要改一下跳跃代码,加上上面的变量:
//Player跳跃 if (buffer_counter < buffer_max) { buffer_counter +=1; //每帧递增 if place_meeting(x, y+1, objSolid) //检测player是否在地上 vspeed = -5; //跳起 }
我们现在加上触发跳缓冲的按钮。你可以自己选个按键,我在这用了空格键。在step事件中的任何位置添加它,我建议接近上面的代码以保持整洁。
//Player跳跃
if (buffer_counter < buffer_max)
{
buffer_counter +=1; //计数器每帧递增
if place_meeting(x, y+1, objSolid) //检测player是否在地上
vspeed = -5; //跳起
}
//Player跳跃操作
if keyboard_check_pressed(vk_space) //按下空格键
buffer_counter = 0; //重置缓冲计数器
WA-啦!现在,即使你在降落前按下跳跃按钮达10帧,我们的跳跃也会有效,很酷吧?只需几行代码,我们就能让我们的跳跃响应更快。输入缓冲可不是这一个用途,你可以将它添加到你任何喜欢的东西里,如攻击,躲闪,甚至导航菜单。希望这个迷你教程有所帮助!
译文:GAMEMAKER STUDIO基础系列:组合技(Combo)设置
作者:Nathan Ranney
翻译:highway★
接上一期Hitbox&hurtbox,这期我们来看看如何设置一个基本的连击系统。
同样,本文依旧面对新手。
概述:组合技,取消和连续技
在我们开始之前,我想指出有不同种类的组合技。一般来说,一个组合就是当一个攻击一个接一个地连接,你的敌人除了一直挨揍什么也干不了。但是,从第一次攻击到第二次攻击的方式可能会有所不同。
chain combo就是说你的第二次攻击中断或者取消当前的攻击。你的第一次攻击在它所在的位置停止,接着打出第二次攻击。
link combo是当你的第一次攻击完全结束,你的角色恢复到他们的中立状态时,你的对手仍然被击晕足够长的时间让你开始第二次攻击并击中他们。
译注:如果你是格斗游戏玩家,会对这些非常清楚,当然了,如果你在读这篇文章,我会假定你很喜欢动作格斗类游戏,咱们在这儿就不多啰嗦了。
攻击精灵图
我们还是用之前hitbox那篇文章中的精灵图,不过要做一些修改,因为我们要做combo。
我们要加两个新的攻击动作。
sprPlayer_Attack_2
sprPlayer_Attack_3
这些有点小,但这是因为它们是游戏的原生分辨率。我还对之前的原始攻击动画做了一些更改。使用我们的新攻击有点太长了,所以我删除了一堆帧以使其适合我们的新精灵。
sprPlayer_Attack
导入精灵长带图(如果有疑问请看F1),将原点设为16x32
初始化新变量
打开oPlayer的create事件,加入下面代码:
currentAttack = attacks.right; hitLanded = false;
currentAttack将用于根据玩家当前正在进行的攻击来定义不同的行为和动画。 hitLanded将是我们的布尔值,我们用它来告诉我们可以取消当前攻击到下一次攻击。
打开enum_init脚本,让我们为新的攻击添加一些新的枚举。将此代码添加到脚本的底部。
enum attacks {
right,
left,
upper
}
最后,我们需要扩展一下hitbox_create脚本,加一个参数。打开该脚本并将以下代码复制/粘贴到其上。
_hitbox = instance_create(x,y,oHitbox); _hitbox.owner = id; _hitbox.image_xscale = argument0; _hitbox.image_yscale = argument1; _hitbox.xOffset = argument2; _hitbox.yOffset = argument3; _hitbox.life = argument4; _hitbox.xHit = argument5; _hitbox.yHit = argument6; _hitbox.hitStun = argument7; return _hitbox;
我们添加了_hitbox.yHit,这样做可以让我们把敌人打到浮空,而不是仅仅向后击退。
打开normal_state脚本,再最下面加入如下代码:
//reset attack currentAttack = attacks.right;
这可以确保我们的攻击始终从攻击链中的第一次攻击开始。这就是初始化新变量和枚举的全部内容。
扩展switch语句
现在我们已经拥有了所有这些新攻击,我们需要为每个攻击设置动画。使用我们刚刚初始化的新枚举和嵌套的switch语句可以轻松实现这一点。打开animation_control脚本,让我们更改states.attack以包含新的精灵图。
case states.attack:
switch(currentAttack){
case attacks.right:
sprite = sprPlayer_Attack;
break;
case attacks.left:
sprite = sprPlayer_Attack_2;
break;
case attacks.upper:
sprite = sprPlayer_Attack_3;
break;
}
break;
attack_state脚本也要更改:
switch(currentAttack){ case attacks.right: //create hitbox on the right frame if(floor(frame) == 1 && hitbox == -1){ hitbox = hitbox_create(20 * facing,12,-3 * facing,-16,8,1 * facing,0,60); } //cancel into next attack if(attack && hitLanded){ currentAttack = attacks.left; hitLanded = false; hitbox = -1; frame = 0; } break; case attacks.left: //create hitbox on the right frame if(floor(frame) == 1 && hitbox == -1){ hitbox = hitbox_create(20 * facing,12,-3 * facing,-16,8,1 * facing,0,45); //also move forward xSpeed = 3 * facing; } //cancel into next attack if(attack && hitLanded){ currentAttack = attacks.upper; hitLanded = false; hitbox = -1; frame = 0; } break; case attacks.upper: //create hitbox on the right frame if(floor(frame) == 1 && hitbox == -1){ hitbox = hitbox_create(20 * facing,12,-3 * facing,-16,8,1 * facing,-4,45); //also move forward ySpeed = -3; } break; }
我在这里要解释一下。首先,我们完成了与animation_control脚本完全相同的操作,并加了一个switch语句,其中包含每种攻击类型的情况。 hitbox_create脚本已更新为包含我们的新yHit参数。这是倒数第二个值。你可能注意到前两次攻击已将其设置为零。那是因为我们不想在这些攻击中将敌人打到浮空。
其次,我添加了一个检查以允许取消进入下一次攻击。我们检查是否按下了攻击按钮并且hitLanded是否为true。如果满足两个条件,则更改为另一个攻击,将动画帧重置为零,并将hitbox设置为-1。如果hitbox变量未重置为-1,则在下一次攻击发生时不会创建新的hitbox。我们还将currentAttack设置为我们想要取消当前攻击后进入的下一次攻击。
最后,当在第二次和第三次攻击中创建了hitbox时,我们也会对玩家角色加点儿动势。您将看到正在设置xSpeed和ySpeed。在attacks.left情况下,这将使玩家向前移动一点,以确保在第一次攻击击退敌人后让下一击能够击中敌人。在attacks.upper案例中设置的ySpeed会将玩家打到空中,对,就等于是简化出招的隆的升龙拳。
Applying yHit
虽然我们将yHit添加到hitbox_create脚本中,但还没应用于敌人。在oEnemy对象中,打开end step事件,让我们加点儿东西。
//get hit
if(hit){
squash_stretch(1.3,1.3);
xSpeed = hitBy.xHit;
ySpeed = hitBy.yHit;
hitStun = hitBy.hitStun;
facing = hitBy.owner.facing * -1;
hit = false;
currentState = states.hit;
}
设置和重置hitLanded
最后一步,打开oPlayer的end step事件,当我们的hitbox打中敌人时,把hitLanded设为true:
//hitbox
if(hitbox != -1){
with(hitbox){
x = other.x + xOffset;
y = other.y + yOffset;
//collision check
//checking the collision from the hurtbox object
with(oHurtbox){
if(place_meeting(x,y,other) && other.owner != owner){
//ignore check
//checking collision from the hitbox object
with(other){
//check to see if your target is on the ignore list
//if it is on the ignore list, dont hit it again
for(i = 0; i < ds_list_size(ignoreList); i ++){
if(ignoreList[|i] = other.owner){
ignore = true;
break;
}
}
//if it is NOT on the ignore list, hit it, and add it to
//the ignore list
if(!ignore){
other.owner.hit = true;
other.owner.hitBy = id;
owner.hitLanded = true;
ds_list_add(ignoreList,other.owner);
}
}
}
}
}
}
打开normal_state脚本并将其添加到脚本的末尾。
//reset hit landed
hitLanded = false;
这将确保在攻击发生之前始终将hitLanded设置为false,因为我们的攻击始终从normal_state脚本启动。
运行游戏并开始打击你的对手!如果你用不同的按键对应3种攻击,你的角色应该执行一个由三种攻击组成的chain combo~
感谢阅读~
译文:GameMaker Studio基础系列:Hitbox和hurtbox(攻击/受击判定盒)
作者:Nathan Ranney
翻译:highway★
注意:本文面向初学者!!!
原文部分函数为GMS,译文中已经改为GMS2的对应函数
观看操作视频或阅读
这篇博客文章概述了设置hitbox和hurtbox的所有步骤和代码。您也可以按照以下视频进行操作:
https://youtu.be/NbOVd4ycZkg (要爬出去)
什么是hitboxes和hurtboxes?
注:这里我还是用原文,原意是攻击判定盒 & 受击判定盒,如果你常玩FTG、ACT或者FPS类型的游戏,对hitbox这玩意儿肯定了解很多。不了解的朋友还请仔细阅读。
基本上,hitbox和hurtbox只是专门的碰撞检测(碰撞检测允许您确定对象何时接触或重叠)。hitbox通常与某种形式的攻击相关联,并描述该攻击的有效范围。hurtbox通常与角色(或游戏中的任何其他“可击中”对象)关联。每当他们俩碰撞时,我们认为攻击已“达成”,我们将其效果应用于目标。下面的内容我会用FTG类型游戏做主要的例子。在我看来,格斗游戏提供了最明显的hitbox和hurtbox示例,使它们非常容易理解。 我们来看看街霸4,如下:
上图里,我们看到Makoto表演了她的一个特殊动作,吹上攻击。这招儿就是向上出拳,通常用来防空,可以击中向你跳跃的对手。红色矩形是hitbox,而绿色矩形是hurtbox。如果Makoto用她的hitbox碰到别人的hurtbox,那么另一个玩家将被“击中”。
现在,默念“box”一千遍,好了,咱们开始设置。
Hurtbox 设置
首先!我们需要一个精灵图用于我们的hurtbox。创建一个新的sprite,命名为sprHurtbox,1 x 1像素,并将其着色为绿色。我们只需要一个像素,因为我们将在实例化hurtbox时将其缩放到我们需要的任意大小。另一种方法是为每个可能需要hurtbox的游戏对象创建一个自定义大小的精灵图,这样很……浪费资源,也很……无聊。
现在我们创建一个object(对象),命名为oHurtbox,精灵图指定为sprHurtbox。添加create事件,敲下面这些。
image_alpha = 0.5; //让hurtbox半透显示 owner = -1; //将绑定到创建它的任意对象的id,比如oPlayer xOffset = 0; //用来跟owner对齐位置 yOffset = 0; //同上
现在我们需要创建一个hurtbox并给它一个持有者。
创建一个script(脚本),命名为hurtbox_create。敲入下面这些代码。(咳咳,哥们儿你别复制粘贴啊……)
_hurtbox = instance_create_layer(x, y, layer, oHurtbox); //创建oHurtbox对象,注,如果你想用其他的layer来显示这玩意,就把layer改为你想要显示的layer名,“layer name” _hurtbox.owner = id; //存储该对象的id _hurtbox.image_xscale = argument0; _hurtbox.image_yscale = argument1; _hurtbox.xOffset = argument2; _hurtbox.yOffset = argument3; return _hurtbox;
如果你以前没怎么写过script(脚本)的话,可能觉得这个看起来有点儿多啊,但其实很简单。首先,我们创建一个oHurtbox对象,并将该对象的ID存储在_hurtbox的owner变量中。然后,使用_hurtbox的变量,我们传入所有者(调用此脚本的任意对象),接着定义了hurtbox的大小和偏移量。现在脚本已经写好了,我们可以来调用一下试试看。打开oPlayer对象把下面的代码加到create事件里。
//hurtbox hurtbox = hurtbox_create(18,24,-9,-24); //hitbox hitbox = -1;
使用我们刚刚创建的hurtbox_create脚本,我们可以很方便的地设置比例和偏移量,并将oHurtbox对象的ID存储在oPlayer对象可以使用的变量中。脚本中使用的数字以像素为单位。我们创建的hurtbox是18像素宽x24像素高,偏移玩家精灵左侧9像素,并偏移玩家精灵上方24像素(注:这说的可够真详细的 =_=)。好了,现在运行游戏看看,hurtbox好像没有跟随你的角色。
我们得解决这个问题。在oPlayer对象中打开end step事件并添加以下代码。如果你看了本系列的前几篇教程,我把这些代码加到了animation code底下。
//hurtbox with(hurtbox){ x = other.x + xOffset; y = other.y + yOffset; }
with和other如果你还没用过的话,我在这里简单解释一下(如果还是不明白的话,还是去仔细看一下F1比较好)。当你使用with后跟对象名称(或特定对象ID)时,花括号里的代码将执行,就像该对象正在执行它一样。so,当我们写with(hurtbox)时,我们正在更新存储在hurtbox变量中的特定oHurtbox对象的x和y位置。
由于我们使用with,我们也可以使用other。这段代码用到other时,它将引用此代码运行的原始对象。在这种情况下,就是我们的oPlayer对象。
好了,现在hurtbox跟随玩家了。
Hitbox设置
现在我们有hurbox了,我们得打它啊! hitbox所需的设置跟hurtbox差不多,但它还有更多功能。 简单理解一下hitbox,首先我们来检测碰撞,要是碰撞了,然后决定接下来要做什么。(哎,我车还没碰到你,你怎么倒了呢?面对一些老年碰瓷者,有可能是咱们的车hitbox出毛病了,要么就是他们的hurtbox的offset或者scale出毛病了吧……这时候可能就需要交警和行车记录仪来debug了 =_=)
就像hurtbox一样,我们需要创建一个精灵和一个对象。创建一个名为sprHitbox的单像素精灵,红色。然后创建oHitbox对象并指定sprHitbox精灵。添加create,step,end step和destroy事件,打开create事件并敲入以下代码。
image_alpha = 0.5; owner = -1; xOffset = 0; yOffset = 0; life = 0; //hitbox存活时间 xHit = 0; //用来击退 x方向 yHit = 0; //用来击退 y方向 hitStun = 60; //击晕时间 ignore = false; ignoreList = ds_list_create();
与我们的hurtbox一样,我们需要设置所有者和偏移量。然而,与受伤害的盒子不同,hitbox并不是一直存在的,它只存在于攻击期间。life变量将用于确定数据框将存在多少帧并保持活动状态。 xHit和yHit是我们的击退变量。hitStun确定我们击中的角色被打中后眩晕的时间。最后,ignore变量和ignoreList列表将用于确保我们不会多次击中一个角色。后面你会看到它是如何工作的。
击中眩晕是一个角色在被击中后被击晕的时长。如果玩家被击晕,除了等着被揍或者祈祷,他们什么都做不了(当然你也可以写成疯狂按键可以稍微减少眩晕时长)!格斗游戏里这玩意儿很常见。你要是把对手打晕了的话,嗯……先来一个挑衅动作,然后一套连招KO好了~ (或者…你也可以点一个轻攻击让对方恢复正常,接着继续干死他…有点儿更藐视对手,是的,我跑题了 : p)
打开destroy事件并加上下面的代码。
owner.hitbox = -1; ds_list_destroy(ignoreList);
这可以确保hitbox在销毁后,其所有者停止尝试与其进行交互,并在不再需要时删除ignoreList。如果列表未被删除,则可能导致内存泄漏。
之后打开step事件,加入下面代码:
life --;
这将在hitbox处于活动状态时从生命周期中减去(就是计时器)。当life变量达到0时,删除hitbox。最后到end step事件,加入下面这一小段:
if(life <= 0){ instance_destroy(); }
当一个对象被破坏时,就像我们上面所做的那样,将调用destroy事件(如果存在)。OK,hitbox设置已经完成了, 但对于实际对象!还有很多事情要做。就像hurtbox一样,接着我们要干嘛?对了,脚本。创建一个新脚本,命名为hitbox_create,然后敲入以下代码(上面的我加了注释,下面的注释我就不加了,作者讲的很细)。
_hitbox = instance_create_layer(x, y, ,layer, oHitbox); _hitbox.owner = id; _hitbox.image_xscale = argument0; _hitbox.image_yscale = argument1; _hitbox.xOffset = argument2; _hitbox.yOffset = argument3; _hitbox.life = argument4; _hitbox.xHit = argument5; _hitbox.hitStun = argument6; return _hitbox;
跟hurtbox那个差不多,多了几样东西,life,xHit和hitStun。 完事儿了吗?我们差不多已经完成了一半。回到oPlayer对象的end step事件,在hurtbox代码段下面加上
这些:
//hitbox if(hitbox != -1){ with(hitbox){ x = other.x + xOffset; y = other.y + yOffset; } }
这与hurtbox代码略有不同,我们要先在攻击那一刻确认此时是否已经有hitbox存在,也就是检查我们的hitbox变量是否不等于-1。
现在,最后一步,我们需要在攻击期间的正确时间实际创建hitbox。但在我们这样做之前,我需要简要介绍一下格斗游戏中攻击的实际构成。所有攻击都分为三个部分。启动(Start up),活跃(Active)和恢复(Recovery)。每一部分都会持续一定的帧数。看看下面的图表会理解的更清晰。
(译注:这个图不翻译了,gif弄着太麻烦了,见谅,但是这个图是精华,一定要仔细看懂)
启动是攻击变为活动所需的时间,然后衔接到出拳。活跃是hitbox能够实际击中敌人的时间。恢复是角色完成攻击并返回中立状态(译注:这里对状态不太熟悉的话,请先详细了解一下状态机)所需的时间,之后才可以再执行其他操作。让我们看看我们的角色精灵,以确定我们的启动,活动和恢复帧应该在哪里。
我们的启动帧是0-2帧。相当于攻击动作的发条。活跃帧为3-4帧,恢复5-7帧。我们需要在第3帧创建我们的hitbox,它需要在第5帧开始之前一直处于活动状态。在我的项目中,我的frameSpeed变量为0.15并且游戏以60 fps运行,我的精灵动画以大约每秒四帧。所以,我的hitbox的生命需要为8帧。
打开attack_state脚本并添加以下行(译注:这个脚本在之前的教程中)。
//在合适的时间创建hitbox if(frame == 3 && hitbox == -1){ hitbox = hitbox_create(20 * facing,12,-3 * facing,-16,8,3 * facing,45); }
我们要检查我们是否在正确的帧上,并且hitbox不存在,再使用hitbox_create脚本创建hitbox。在创建hitbox时,我们需要将水平值(xscale和xOffset)乘以角色面向的方向。这确保了hitbox始终与角色的方向对齐。然后我们设置了8帧的存活时间,然后是水平击退和击晕。现在运行游戏并按下攻击,你应该会看到hitbox出现并按预期消失。现在我们得让它能打东西了!
TIPS:hitbox越大,它就越强大。生活也一样。 hitbox活动的时间越长,它就越强。在格斗和动作游戏中,巨大的,持续时间长的hitbox总是非常强大(想想那些恶心人的BOSS吧)。在设计攻击时请记住这一点!
敌人设置
拳击手需要沙袋,而我们,需要一个敌人。这将非常简单,因为敌人将使用与我们的玩家相同的许多代码(译注:通常玩家和敌人会隶属于一个Entity的父类对象,这样就不用重写类似的代码了)。现在,我们需要添加一些新的精灵。你可以使用你想要的任何精灵,或者用我正在使用的相同精灵(译注:效果可能没那么好,比如受击、跳)。
以与创建玩家精灵相同的方式创建精灵。确保精灵原点是(16,32),就像上次一样!你应该有两个精灵:sprEnemy_Idle和sprEnemy_Hurt。 复制oPlayer对象并将其命名为oEnemy(译注:如果你有一个oEntity的对象的话,就可以更方便了)。将sprEnemey_Idle sprite分配给对象,然后打开create事件。我们需要添加一些新变量:
hit = false; hitStun = 0; hitBy = -1;
hit是一个简单的布尔值,我们将在应用命中效果时使用到它。接下来,hitStun是被击中后敌人在hitStun中停留的时间。最后,hitBy将是击中它们的对象的ID。
接着打开step事件。删除与player按键和状态切换有关的代码段(译注:如果你有一个oEntity的对象的话,没必要这么麻烦了)。当我们按下按钮时,我们不希望敌人执行动作,我们需要重写状态切换。加入以下代码。
//状态切换 switch currentState { case states.hit: hit_state(); break; }
由于我们的敌人只会站着或被击中,我们现在不需要任何其他状态。但是我们确实需要创建hit_state脚本。立即执行此操作并添加以下代码。
xSpeed = approach(xSpeed,0,0.1); hitStun --; if(hitStun <= 0){ currentState = states.normal; }
如果你已经读到这里了,那这对你来说应该很熟悉。首先,我们降低敌人的水平速度,直到达到零。接下来,我们让hitStun倒计时,并在hitStun达到零时将敌人恢复到默认正常状态。很简单吧! 再打开end step事件。首先,把animation_control()改成 animation_control_enemy();然后在hurtbox代码下面添加这个。
//被打了~~ if(hit){ squash_stretch(1.3,1.3); xSpeed = hitBy.xHit; hitStun = hitBy.hitStun; facing = hitBy.owner.facing * -1; hit = false; currentState = states.hit; }
这是我们应用命中效果的地方,如击退,挤压和拉伸,屏震(如果你想要这种效果的话),等等。它还将敌人状态更改为受击状态,这会阻止他们在击中昏迷时执行任何其他操作。 现在,我们要创建animation_control_enemy脚本。这是玩家使用的相同类型的脚本,但是简化了,因为敌人的动画和行为比玩家少很多。加入下面的代码(注意精灵名是否与你的资源匹配):
xScale = approach(xScale,1,0.03); yScale = approach(yScale,1,0.03); //animation control switch currentState { case states.normal: sprite = sprEnemy_Idle; break; case states.hit: sprite = sprEnemy_Hurt; break; } //reset frame to 0 if sprite changes if(lastSprite != sprite){ lastSprite = sprite; frame = 0; }
这里没什么好说的。我们所做的就是根据状态设置精灵,就像我们对玩家一样。 OK,敌人设置完成!放在房间里一两个敌人。下面到了比较难的部分了...检查hitbox / hurtbox碰撞(重叠),并解决该碰撞。
击中检测和确定攻击
这部分有点儿绕。还记得with和other么?嗯...我们还要用到它们,但嵌套在自己内部。告诉对象在另一个对象内部的另一个对象内部做什么!对象开始!好吧也许它并不复杂,但有时读起来就有点儿费劲...... 不管怎样,咱们先回到oPlayer对象并打开end step事件,你可以在其中更新一下hitbox代码段,让它看起来像这样。
//hitbox if(hitbox != -1) { with(hitbox) { x = other.x + xOffset; y = other.y + yOffset; //check to see if the hurtbox is touching your hitbox with(oHurtbox) { if(place_meeting(x,y,other) && other.owner != owner) { //do some stuff } } } }
快速回顾一下这里发生的事情。我们检查当时是否确实有一个hitbox,如果有,我们会检查所有的hurtbox对象,看看它们是否与这个特定的hitbox实例发生碰撞。使用with时请务必注意,如果您只使用对象的名称(如oHurtbox)而不是对象的实例ID,则将从该对象的所有实例中运行代码。现在我们是两层深,并且正在检查来自hurtbox的碰撞,所以当我们使用other时,它不再引用运行所有这些代码的主对象(oPlayer对象),而是作为一个层的对象在这一个之上(oHitbox对象)。 查看下面的图表,可以直观地了解正在发生的事情。
oPlayer用于与oHitbox通信,然后oHitbox使用with与oHurtbox进行通信。每次调用都会为代码创建一个新层。当一个对象正在使用其他对象时,它会引用它上面的层。必须了解这些层以及with/other,才能完全理解这些碰撞检测将如何工作。
最后,我们需要解决碰撞。我们已经检查了hitbox和hurtbox是否发生了碰撞,现在我们需要决定接下来会发生什么。好了,我们的ignore、ignoreList登场啦。首先,我们需要检测,看看hitbox是否已经击中了hurtbox。
//hitbox if(hitbox != -1) { with(hitbox) { x = other.x + xOffset; y = other.y + yOffset; //检测hurtbox是否碰到了hitbox with(oHurtbox) { if(place_meeting(x,y,other) && other.owner != owner) { //ignore检测 //检测来自hitbox对象的碰撞 with(other) { //检查你的目标是否在忽略列表中 //如果是,不要再次击中它 for(i = 0; i < ds_list_size(ignoreList); i ++) { if(ignoreList[|i] = other.owner) { ignore = true; break; } } } } } } }
好多花括号......甭担心。在确定我们的hitbox与一个hurtbox相撞后,我们不得不再做一个功能,并且这两个判定盒有不同的Owner(持有判定盒的对象)。Owner检查可防止hitbox与属于玩家的hurtbox碰撞,从而阻止玩家自己打到自己。
接下来检测我们要忽略的敌人列表。如果你之前从未使用过for循环,这可能会让人感到有些困惑,但它看起来要简单得多。 for循环执行一定代码块一定次数。在这儿,它执行的次数与ignoreList中的数据实例一样多。它会检查列表中的每个位置,并将其与刚刚碰撞的hurtbox的owner进行比较。如果列表中的任何数据与hurtbox的owner匹配,则忽略owner,并且不会被命中,我们将使用break停止检查列表。我们这样做是为了防止同一个敌人在我们的攻击活跃的每一帧被击中。如果这个忽略检测不存在,则敌人将在8帧中被击中8次。
你可能想知道ignoreList 如何填充数据,我们后面再说。如果我们的第一次检查失败,也就是说,如果不应忽略敌人,我们可以击打它们并将其数据添加到列表中。对你的代码进行以下更改。
//hitbox if(hitbox != -1) { with(hitbox){ x = other.x + xOffset; y = other.y + yOffset; //check to see if the hurtbox is touching your hitbox with(oHurtbox){ if(place_meeting(x,y,other) && other.owner != owner){ //ignore check //checking collision from the hitbox object with(other){ //check to see if your target is on the ignore list //if it is on the ignore list, dont hit it again for(i = 0; i < ds_list_size(ignoreList); i ++){ if(ignoreList[|i] = other.owner){ ignore = true; break; } } //if it is NOT on the ignore list, hit it, and add it to //the ignore list if(!ignore){ other.owner.hit = true; other.owner.hitBy = id; ds_list_add(ignoreList,other.owner); } } } } } }
如果ignore为false,那么hurtbox(other.owner)的持有者就会被击中!我们需要告诉它被击中的对象(other.owner.hit = true)以及击中它们的对象(other.owner.hitBy = id)。然后将它们添加到忽略列表中,这样我们就不会在下一帧再次点击它们(ds_list_add(ignoreList, other.owner)。现在运行游戏,去揍你的敌人们吧!他们应该会被击中、击退、并被击晕(译注:当然,在动作或格斗游戏中,单一的普通攻击是不会击晕敌人很长时间的,这里的敌人硬直时间可能稍长,击退距离也略显长,这里是教程作者为了便于理解有意为之)。
最后的想法
哇。好累啊!很高兴你能看完,真棒~ 当我开始写这篇文章时,我并没有预料到需要这么长时间(译注:嗯,别说写了,我都没想到要花这么长时间来翻译,真的很累……)。我很高兴能够展示很多有趣的概念,例如with / other,for循环,ds_lists和简单的碰撞检测。
我非常感谢你花时间阅读这篇文章,我希望你能学到新东西。你可以在Twitter上关注我,并在我的网站上关注更多与游戏开发相关的内容!有关本文中介绍的一些新主题的更多信息,请查看以下链接。
重要链接
- Part 1 of GameMaker Basics: Drawing Sprites
- Part 2 of GameMaker Basics: State Machines
- Part 3 of GameMaker Basics: Juicing Your Movements
- Sprites and animation by Alexander Prokopiev
- GameMaker final project file
- Ds_lists in GameMaker
- About the With function in GameMaker
- Keywords in GameMaker
- Loops in GameMaker
- AABB (Axis-Aligned Bounding Box) Collision Detection
嗯,我就是,又帅又爱分享的Nathan
用GameMaker Studio 2进行Debug
作者:ZackBell
翻译:highway★
在这篇文章中,我将解释我在调试游戏时的过程,以及一些快速发现错误并找出游戏瓶颈的提示。 我会尽可能多地讨论这些工具,但鉴于它是如此广泛的话题,我很可能会错过几点。 您自己尝试所有这些工具并找出方法让您的调试体验变得更轻松,更好。 阅读官方文档也是在阅读本文后了解更多信息的好方法。
调试和分析究竟是什么?
我们都在这种情况下编写代码,运行游戏,并没有像预期的那样工作。 然后我们回到代码,看了20多分钟后,感觉是,“这没问题,应该好使啊”。 我们首先想到的解决方案是通过使用调试消息或在屏幕上绘制文本来检查变量的值或条件语句的结果。(译注:比如我……)
这对于快速调试一个小功能来说很好,但是当你有很多移动部件一起工作时,生成这些消息可能会很快失去控制。 这时候debugger(调试器)就变得很有用了, GameMaker的debugger允许您逐行运行您的代码,检查每个变量的值及其变化。 它还可以显示更多高级信息,例如纹理和表面的状态,当前图形选项,缓冲区的值等。
在项目的后半部分,由于对象互相交互,您的游戏速度会变慢,因此分析通常更有用。 这是探查器派上用场的地方,它提供了游戏运行时每个阶段调用哪些函数的详细信息,并以易于分析的方式编译数据。 一旦找出缓慢运行的功能/部分,就可以对其进行优化。
进入调试模式
让我们从基础开始。 您可以通过按顶栏上的Play按钮或按F5启动您的游戏。 但是,要使用附加的调试器启动游戏,则需要按下Bug图标,或者也可以使用F6。
游戏启动后,您将在代码编辑器的顶部看到一个新选项卡,并打开多个其他窗口。 您可以自定义这些窗口中的每一个窗口的布局,我的布局如下图:
如果窗口没有弹出,或者您意外关闭了窗口,请转到Debugger→Windows,并且会显示一个可以显示的可用窗口的完整列表。
调试工具栏
一旦进入调试器选项卡,您将在顶部看到一组按钮。 让我们回顾其中的每一个,我们将在文章中详细讨论更多细节。 这是工具栏的外观:
从左到右,依次为这些部分:
- 继续,中断和重新开始游戏:这些按钮让我们暂停(中断)程序,继续下一个断点(或者如果没有设置其他断点,继续执行),然后重新启动程序。
- 关闭游戏:与上面类似,可以停止游戏的执行。
- 步入,跳过和跳出:这些按钮在应用程序暂停时逐行推进我们的代码。 你会很频繁的使用这个。
- 实时调试:这是一个切换按钮,用于控制我们是否希望在应用程序运行时或者仅在暂停时更改调试器中的变量。
- 丢弃收集的数据:在调试时,调试器会保留它在上次运行时收集的数据,以便在关闭应用程序后引用它。 该按钮将丢弃所有的数据。
- 内存,FPS和彩色圆圈:显示当前内存使用情况,当前每秒帧数(FPS),当调试器连接并且应用程序正在运行时,该圆圈将变为绿色,否则为红色。
有三种方法可以在特定行上设置断点:
- 在你想要断点的行上左键点击行号槽。
- 右键单击一行,然后左键点击Toggle Breakpoint。
- 选择一行,然后按F9。
请记住,如果您在空行或带注释的行上设置断点,则游戏将在下一个有效行中暂停。
现在我们已经设置了一个断点,如果我们使用调试器来运行游戏,应用程序将在该代码行执行之前自动停止。 因此,例如,如果您停在一个为变量赋值的行上,变量本身不会有该值,但只要我们进入下一行,它就会改变。 即使游戏正在运行,也可以在任何时候设置或删除断点。
游戏在我想要的部分暂停了, 然后呢?
游戏暂停时,您可以用几种不同的方式检查变量的值。 最简单的方法是将鼠标悬停在代码中的变量名称上。 知道当前行上面的变量值已经更新了这个帧,而下面的值没有。 这也意味着如果你有一个局部变量,他们将不会被初始化。
以这种方式检查值对于整数或字符串等变量类型非常有用,但是从数据结构或对象获取的信息并不太有用。 在这些情况下,最好使用变量和实例调试窗口来获取更多信息。
默认情况下,“Variables变量”选项卡中有三个部分。 在Locals窗口中,您将看到所有局部变量的值,其中包括self。 如果单击self对象上的加号,则会看到与正在检查的对象关联的所有实例变量。 中间的Globals窗口,向我们展示所有已定义的全局变量及其值。 最后,在右侧的Watch窗口中,我们可以指定变量随时跟踪。 要在Watch窗口中添加变量,我们可以点击“Add new Watch ...”,然后键入变量的名称,或者右键单击其他窗口中的任何变量(本地,全局,实例,所有实例)和 然后选择“添加监视(Add Watch)”。请注意,如果您尝试观察当前超出范围的变量,则调试器将无法引用它(它将显示<无法评估(unable to evaluate)>)。
有时你会发现,当你真的想看到它的值时,变量不会显示你想要的信息,例如实例或数据结构ID。 在这些情况下,您只需右键单击该变量,将鼠标悬停在“查看为(View As)”上,然后选择您希望变量显示的方式。
这是事情变得有趣的地方,因为你可以做的不仅仅是查看变量的值。 实际上,您可以通过双击值单元格然后输入新值来更改所需的任何值。 这个改变会立即影响变量,让你在不需要重新编译游戏的情况下即时测试。 如果以这种方式更改某个值,则在下次运行游戏时不会再发生,因此如果你觉得某个值的效果更让你满意,那就记下来之后在代码里替换掉。
现在我们可以看一下“Instances实例”选项卡,它默认包含三个部分。 第一个标记为Instance的实例包含所有有关运行代码的实例的变量和信息。 您将看到您创建的变量的值,还有所有GameMaker中的所有内置变量和对象,比如ID,位置,物理变量,计时器等。
您还可以搜索在“All Instances所有实例”选项卡中创建的每个实例。 最后,如果在游戏窗口中单击某个实例,它将显示在“Selected Instance选定实例”部分,但前提是该对象具有GameMaker可引用的碰撞掩码(collision mask)。 如果您的房间中有同一对象的多个实例,并且需要选择特定的对象,这对于使用“所有实例”部分来说很困难,因为它们都具有相同的名称,这是非常有用的功能。
在暂停时浏览您的代码
我已经提到你可以在游戏暂停后,逐行运行你的代码,无论是手动还是断点,但还没有解释如何做。 如果你看看调试栏,你会注意到这些图标:
这些是您在浏览代码时的助手。 从左到右,这些按钮是:步入,跳过和跳出。
步入
当你按下这个按钮时,代码将执行下一个逻辑步骤。 如果你的行有一个变量赋值,它将执行它,然后移动到下一行。 但是,如果您徘徊的线路有脚本调用,那么它将跳转到该脚本并停在第一行。 您可以继续浏览脚本的代码,直到您到达结尾,然后返回到初始呼叫。 请记住,只有用户定义的脚本是这样,而GameMaker函数则不会,因为它们的实现对用户是隐藏的。
跳过
这个和前一个类似,我们逐行地逐行执行代码,但这次如果你在一个函数调用的行中,你将不会被接受。 相反,它将执行该脚本中的所有操作,出来,然后进入下一行。 除非我正在做一些深入的调试部分,我需要检查每一步发生的情况,这是我使用最多的按钮。
跳出
最后,这个按钮会在调用堆栈中向上移动一步。 如果您不确定调用堆栈是什么,那么它就是为了达到您所使用代码的一部分而执行的函数调用列表。因此,例如,如果您处于对象创建事件的范围内,然后调用一个脚本在其内部调用另一个脚本,那么你将深入三个函数。 如果现在按下Step Out按钮,则会进入堆栈中的上一步(第一个脚本调用)。 如果再按一次,则会返回到对象创建事件。 当你在一个脚本内部时,这很有用,你知道它正在按照预期工作,并且想要回到调试你开始的部分。
如果您想跳过其中的大部分代码,另一个导航代码的好方法是将另一个断点放在要到达的位置,然后按下Play按钮。 这将使游戏继续执行,直到它到达下一个应该是您刚放置的断点。
如前所述,您也可以按Pause 按钮来停止游戏的执行,但这会在游戏当前执行的任何地方停止,因此它可能是一个随机位置。 您也可以通过按播放/暂停按钮旁边的环形箭头重新开始游戏。
Real-time debugging实时调试
大多数调试器只允许您在应用程序暂停时检查变量。 在由GameMaker提供的最新工具迭代中,可以在游戏运行时观察变量的状态。 如果按下代码步骤图标旁边的按钮,您将激活它。 在运行游戏时,变量的更新速度会尽可能快,并不总是每帧更新一次,但通常可以使用。 并非所有的窗口都可以使用这个功能,并且它的使用主要是监视实例被创建/销毁以及实时变量值。
这些工具可以在90%的情况下有效地帮助调试您的游戏。
更多资源
在我的下一篇文章中,我们将介绍一些有助于调试表面(surface),着色器(shader)和自定义绘制(draw)代码的工具。当然F1更重要,或者在线看Debugging部分。
译注:当东西越做越多,越来越多交互影响的时候,游戏一旦出现BUG经常会让人陷入焦虑,灵活的利用GMS2自带的debug工具,能让我们更快的找到自己的问题所在。希望这篇文章对跟我一样的GMS2初学者有些帮助~ 共勉
如何让平台游戏玩起来更有“感觉”
作者: Zack bell
翻译:highway★
超级肉肉哥Super Meat Boy和洞穴探险Spelunky在我最喜欢的游戏名单上,他们是很出名的平台类型游戏并且在发布后获得了很大范围的成功。主题、设计、音效和视觉效果都很重要,但是将这其推向顶峰的是他们的游戏感。
平台类型的游戏的难度通常都很高,这也决定着操作的流畅和精确性很重要。我很高兴自己开发了两个颇具挑战性、快节奏的平台游戏,在这我想分享一下我认为这些游戏做的很出色的地方。
速度
强烈的速度感既有趣又很重要。当然也有例外,当然这里我指的是那些将动作作为重要游戏特点的平台类型游戏~ 让我们说说超级肉肉哥和N+(译注:谐音ninja,flash游戏,后来上了nds和psp上平台),速度通常会让玩家感到自己对游戏中的角色操控感更强,玩家也喜欢角色能玩出更多的花样,而且最好是控制起来很敏捷(译注:那些走路匀速慢悠悠、跳起来慢悠悠软绵绵、落下去慢悠悠像是漂浮一样的平台游戏恐怕没有多少人能玩进去吧……)。即时改变整体游戏速度,同时保持所有的物理效果也能让玩家感受到很棒的操控感。
得走快点儿
阻力
这个因游戏而异,只是我的个人观点,但这个其实挺重要的。我是说,如果玩家在移动角色时,停止操作手柄(或者键盘)时,玩家角色在游戏中会如何反应。角色会沿着地面滑动?还是突然停下?还是滑动一会再停下(译注:在super mario中,任天堂的做法是加入了移动的惯性,这也给后面的玩法增加了很多花样还有……难度)?在空中会如何表现?我发现在地面上有更高的阻力会感觉更好。角色在1帧或者2帧没有输入的情况下停止移动。但是,我经常使用2个阻力值。第2个非常低,在角色处于空中时使用。这模仿了比较自然的跳跃弧线(跳起后转身并不是一个好做法,而且也比较反自然),同时让玩家在跳跃时对角色进行更独特的控制。
跳跃后立即松开按键
墙跳
我喜欢称之为“胶带墙跳”。(译注:的确很形象……)
墙跳存在的目的是什么?让玩家通过墙达到更高的位置,或者利用墙来进行反向快速离开。你可以通过检测玩家的手柄摇杆(或者键盘)输入并采取行动来确定他们的目标。如果玩家靠近墙壁,我会用最小的水平力和最大的垂直力量将其踢离墙壁。这有助于玩家在墙上移动。如果玩家远离墙壁,我就切换一些数值。这会推动玩家直接离开墙壁。
那跳跃弧线是怎么改变呢?当玩家要“拥抱”墙壁时,它应当是球形的,以便你可以跳起来绕过附着在墙上的障碍物。
贴墙
另一个副作用是,玩家如何看待墙跳的原理。在墙跳之前(当在墙上想调到别的地方时),玩家会向远离墙壁的方向推动摇杆(或者方向键)。这通常会导致玩家在按下跳跃按钮前离开墙壁并下落。这就比较让人难受了,会让玩家觉得好像需要延长输入…… 跳,等一下,然后再推摇杆。
为了解决这个问题,我们可以试试在从墙上推开玩家的时添加一个计时器。如果玩家的左侧有相邻的墙,则在他们尝试向右移动时启动计时器。如果计时结束,让玩家离开墙壁。他们很可能会在计时器结束之前尝试跳跃。
我推荐4-8帧
反馈
如果一切都没成功,那就给玩家更多的反馈。让他们知道自己正在快速的移动。让他们的反应更快。
在下图中,我让player在跑步时/跳起/墙跳/落地时都创建了灰尘效果,在跳起/落地时挤压拉伸sprite,以及跑步时碰到地表的植物时,让它们移动。
thx
GMS:理解碰撞基础 P1
作者:Zack bell
翻译:highway★
有问题联系我 -> zbell91@comcast.net
或者公开提问 -> http://ask.fm/ZackBellGames
碰撞基础我几乎每天都会被问到这个问题,所以我觉得应该多花点时间聊聊它。我如何实现不同角度的斜坡和更高级的碰撞特征。说实话,我认为这是很简单的碰撞检测。但是,我明白这对很多人来说并不是很直观。好在,你一旦明白是怎么回事了,那就不会再有困惑。碰撞涉及到的概念可以很容易的扩展到可跳穿的平台、移动地形等等。
1 - 首先检测坚固平坦的块状对象。基本上,玩家应该在与地面、天花板和墙壁时停下。
vx和vy变量是player的水平和垂直速度
// 垂直碰撞
repeat(abs(vy))
{
if (!place_meeting(x, y + sign(vy), oParSolid))
y += sign(vy);
else
{
vy = 0; break;
}
}
// 水平碰撞
repeat(abs(vx))
{
if (!place_meeting(x + sign(vx), y, oParSolid))
x += sign(vx);
else
{
vx = 0;
break;
}
}
上面的代码解释一下,我们就是在循环中问下面这些问题:
- 1 - 在我移动的方向(上/下)上是否有1个像素的碰撞?
- 2a - 如果没有,继续向原方向移动1个像素。
- 2b - 如果有,那重置速度(上/下),退出循环。
- 3 - 重复1。
重复这个循环,直到一个碰撞导致你跳出循环,或者你已经在循环中迭代了"vy"次。水平移动缓和跟这个一样。
2 - 接下来我们让player上下斜坡。更具体的说,斜坡就是每个step增加player的y坐标1个像素。
粗体是我们增加的代码
// 垂直碰撞
repeat(abs(vy))
{
if (!place_meeting(x, y + sign(vy), oParSolid))
y += sign(vy);
else
{
vy = 0;break;
}
}
// 水平碰撞
repeat(abs(vx))
{
// 上坡
if (place_meeting(x + sign(vx), y, oParSolid) && !place_meeting(x + sign(vx), y - 1, oParSolid))
--y;
// 下坡
if (!place_meeting(x + sign(vx), y, oParSolid) && !place_meeting(x + sign(vx), y + 1, oParSolid) && place_meeting(x + sign(vx), y + 2, oParSolid))
++y;
if (!place_meeting(x + sign(vx), y, oParSolid))
x += sign(vx);
else
{
vx = 0;break;
}
}
你会注意到,在垂直碰撞上没有任何变化。我写的初始循环会让player不会掉穿平台。但是,我对水平碰撞循环进行了两处修改,这些是为了实现上下斜坡。
// 上坡
1 - 你正在移动的方向上(左/右)是否存在碰撞 && 如果player向上移动1个像素且在该方向没有碰撞 ?
2 - 如果有碰撞,靠近斜坡并将player向上推1个像素。循环的剩余部分将搞定将player在水平方向上向斜坡移动。
// 下坡
1 - 你正在移动的方向上(左/右)是否存在碰撞 && 如果player向下移动1个像素且在该方向没有碰撞 && 如果player向下移动2个像素的话在该方向产生碰撞 ?
2 - 如果满足上面的所有条件,你就可以下坡了。
第三次检测(下移)是检测你是不是站在边缘/悬崖上。如果不是,那所有的角落都会被当作是斜坡。3 - 最后,你可能想要处理更陡的斜坡。我会展示一个斜坡示例,每个step调整player的y左边2个像素。
跟之前一样,粗体是我们增加的代码
// 垂直碰撞
repeat(abs(vy))
{
if (!place_meeting(x, y + sign(vy), oParSolid))
y += sign(vy)
;else
{
vy = 0;break;
}
}
// 水平碰撞
repeat(abs(vx))
{
//上坡
if (place_meeting(x + sign(vx), y, oParSolid) && place_meeting(x + sign(vx), y - 1, oParSolid) && !place_meeting(x + sign(vx), y - 2, oParSolid))
y -= 2;
else if (place_meeting(x + sign(vx), y, oParSolid) && !place_meeting(x + sign(vx), y - 1, oParSolid))
--y;
//下坡
if (!place_meeting(x + sign(vx), y, oParSolid) && !place_meeting(x + sign(vx), y + 1, oParSolid) && !place_meeting(x + sign(vx), y + 2, oParSolid) && place_meeting(x + sign(vx), y + 3, oParSolid))
y += 2;
else if (!place_meeting(x + sign(vx), y, oParSolid) && !place_meeting(x + sign(vx), y + 1, oParSolid) && place_meeting(x + sign(vx), y + 2, oParSolid))
++y;
if (!place_meeting(x + sign(vx), y, oParSolid))
x += sign(vx);
else
{
vx = 0;
break;
}
}
并没那么复杂,对吧~简单碰撞的关键是迭代对象的移动路径。前方是否有碰撞?如果没有,那么移动。否则,停止。
你可以试试更复杂的~ 你可能注意到了我检测2像素和1像素斜坡的方式。 你可以在内部循环中设置一个变量(比如slopeMax),并在任意陡度的斜坡上进行迭代。此外,当你上很陡的斜坡时,你可以调整player的移动速度。
thx
译注:不知道indienova这活见鬼又反人类的文字编辑工具什么时候能升级一下……代码的格式我没改,全部贴边儿,nova的编辑太麻烦了,见谅)
在GMS2中使用Surfaces实现屏幕撕裂 / 波纹效果
作者:nikles
翻译:highway★
在玩过 Environmental Station Alpha之后,我也想实现Hempuli(上句那个游戏的开发者)在他的游戏做出的效果。我不知道该如何实现,所有我不得不从头开始,思考不同的方法。
我对shader(译注:着色器,如果你是初学者,还是敬而远之,对美术基础和数学还有编程的要求挺高的)一窍不通,所以只能用surface了。我写了一些代码,然后立刻撞墙……我有点儿懵逼,就去yoyogames的论坛上求助,看了其他人的评论之后,我终于想出一个相当不错的解决办法。
实际代码
在你的游戏控制object(起名随意,比如oGame、oCont之类的)的create事件中,写下面的这些东西
代码:
// 名字缩写,方便后面用,要不然代码太长,看着费劲
dw = display_get_width()
dh = display_get_height()
tearings_surface = surface_create(dw, dh) // 我们要把这个绘制到surface
tearings_y = 0
band_num = 16 // 屏幕上要出现多少个横条
band_height = dh / band_num
tearings_x_offset = 32 //你要怎样水平移动横条
tearing_speed = 4 // 修改这里可以加速/减速
I place the following code inside a draw_post event of my controller.下面这些东西写在刚才那个object的draw_post事件中。
代码:
// 如果surface存在便创建它
if !surface_exists(tearings_surface)
tearings_surface = surface_create(display_get_width(), display_get_height())
// 给surface设置目标
surface_set_target(tearings_surface)
draw_clear_alpha(c_black, 0)
// 我们将部分应用surface绘制在撕裂surface上
for (var current_band = 0; current_band < band_num * 2; current_band++)
{
draw_surface_part(application_surface, 0, band_height * current_band - tearings_y, dw, band_height, sin( (degtorad(360) / band_num ) * current_band) * tearings_x_offset , band_height * current_band - tearings_y)
}
// 始终重置目标surface
surface_reset_target()
// 绘制实际surface
draw_surface_stretched(tearings_surface, -tearings_x_offset, 0, dw + tearings_x_offset * 2, dh)
// 移动撕裂
tearings_y = (tearings_y + tearing_speed) % (band_height * band_num)
搞定.
我的做法就是这样~我还会再弄一个垂直撕裂的类似版本出来(水下关卡?没准儿~)希望这篇小文章对你有帮助。
关于surface的使用要注意:如果你不用的时候,请记住一定要释放surface,否则会引起内存泄漏越来越卡或者可能崩溃。
[ 分享 ]Spelunky关卡生成
Part1. 创建Solution path - 关卡通路
关卡生成算法的第一部分:生成关卡的critical path(关卡通路),我们先不考虑那些障碍物、陷阱、敌人和宝箱什么的。
这个关卡由16个房间按照4x4的网格方式组成,这里有4种不同的房间类型:
- 0:贴边儿,不在critical path上
- 1:左右一定有出口
- 2:左右下一定有出口,如果有2号room在上方,也一定有上方出口
- 3:左右上一定有出口
第一步在最顶部这行随机选取一格创建一个Start Room,房间类型对开始的房间来说没太大影响。一般来说,Start Room类型我们会选1或者2。
每当一个Room被创建,首先总是类型1(左右)。
然后要决定往哪边走。取一个均匀分布的随机数,从1到5。当1或2时,critical path向左;当3或4时,critical path向右;当5时,path向下。(当critical path碰到屏幕边缘,立即向下移动并同时切换左右方向。)
这有个问题,如果我们向左或右移动,是OK的,因为我们创建的Room是一定有左右出口的。但我们要向下走,我们就得改变我们当前所在的房间了。所以生成器重写房间类型为2,覆盖当前的房间,现在我们可以向下移动了。
当生成器移到下一个房间,会问我们最后创建的那个房间(也就是刚才我们头顶上那个)类型是不是2(底部有出口)。 如果是2,那么当前这个房间类型一定要是2或3(上方有出口)。因为2、3类型都有左右出口,启动算法从头再来一次。
如果到了底部这一行,我们要尝试向下,那肯定不行了,我们放置一个Exit Room来取代向下的房间。
现在我们已经生成了整个critical path(关卡通路,即图中那些暗红色的格子,游戏中是不显示的,这里只是为了举例)。接下来要做的最后一件事,我们要把4x4的网格中的空余部分放上类型为0的房间,这些房间并不在critical path上。这些房间在任何方向都不一定有出口,所以有时候会生成一些围墙。
如果一列中有3或者4个Room 0,那这一列房间有几率成为一个蛇窝。如果我们要生成蛇窝的话,从上到下,放一列房间,类型为7 8 9,或者7 8 8 9,取决于我们希望这个蛇窝有多深。(这里的蛇和宝石并不像其他敌人那样随机生成,因为它们是这种地形的一部分,所以位置基本是固定出现。)
蛇窝
Part2. 创建房间
关卡生成算法的第二部分:生成Room,我们先不考虑那些障碍物、陷阱、敌人和宝箱什么的。
Spelunky并不像其他大部分游戏(平台游戏,platformer)在传统的2D tile-base的平台游戏中,你用关卡编辑器做出的地图可能看起来像这样:
在这种类型编辑器里,是这样的,比如,在(64,128)这个位置是草的tile。但Spelunky完全不是这样。
上一部分我们了解了Spelunky有4种基本房间类型(types)。这每个房间类型有8-16个模板。这些模板具备一个房间的基本布局,包括一些静态和概率tile的组合。
Room Templates(房间模板)
每个房间类型都有一堆不同的模板。
译注:Derek Yu使用不同的布局,做了一系列的房间设计。比如你掉出去的房间(DROP)、掉入的房间(LANDING)、可以穿过的走廊(CORRIDOR)、不在关卡通路上的房间(NON-CRITICAL)。
房间模板,嗯……看起来就像下面这样:
110000000040L600000011P000000011L000000011L5000000110000000011000000001111111111
每个模板由10x8 的tile网格组成。"0"表示空,"1"表示100%是砖块,"L"是梯子,"P"是爬梯子上去的平台(platform)。现在你在看这个模板, 嗯,下面一排是地面,左边是2个tile宽的墙,上面有个小缺口,有个梯子可以爬到那。
Static and Probabilistic Tiles( 静态和概率Tile )
static tile就像传统关卡编辑器里一样,你让这里有个砖块,那这就永远是砖块了。
上面模板里的"4" tile,就是probabilistic tile。"4"表示那有25%的几率会是一个能推动的障碍物,出现在梯子的顶部旁边。有经验的Spelunky玩家会一下认出这个:有时你爬到一个梯子顶部,旁边有个障碍物,你得推它,就可以进到下一个房间。有时候爬到梯子顶上旁边就没有这玩意。还有比如“这有33%的几率会出现个尖刺”,或者“这有一半的几率是空的,或者是砖块”。
Obstacle Block( 障碍块)
"5"和"6"就是所谓的Obstacle Block。Obstacle Block由 5x3 的tile网格组成,是个有趣的小结构,会让玩家针对这个障碍来思考自己的策略。Obstacle block本身也会由一些概率tile组成。下面是"5" 这个模板,表示放置在地上("6"表示放置在空中):
000000010271177
规则和之前的一样,0是空,1是砖块。你能看出来,这是个小台阶。"7"有33%的几率是个尖刺,或者为空。如果运气不好,这里就得跳到那个台阶上面避开尖刺。"2"表示有一半的几率味空,或者是砖块。这就有意思了,有可能有个小砖块让你落脚不用踩到尖刺,也有可能除了小台阶,旁边都是空的,玩家得跳到上面去。你可以看出,这么个小障碍块,如果设计的好的话,游戏自己就会随机生成很多很多有意思的场景。
译于2018-Jan-20
原文链接最好用chrome打开,对,要科学,你懂了
http://tinysubversions.com/spelunkyGen/
http://tinysubversions.com/spelunkyGen2/