이동 방식 만들기 — 비행, 탈것, 도구 동작
비행, 탈것 물리, 대시, 벽 타기처럼 게임의 이동 자체를 바꾸는 법입니다. 게임 쪽 코드가 플레이어를 즉시 움직이고(예측), 서버 쪽 코드가 같은 규칙으로 검사해 막습니다. 예: plugins/flight(비행), plugins/riding(탈것과 지팡이).
규칙은 한 번만 (shared.rs)
규칙(최대 빠르기, 최고 높이, 키가 만드는 속도)은 src/shared.rs에 한 번 씁니다. 게임 쪽은 그 규칙으로 움직이고, 서버 쪽은 PlayerMove에서 같은 규칙으로 검사합니다. 서버와 게임의 샌드박스 모두 NaN을 정규화하므로 shared.rs의 계산은 양쪽에서 같습니다.
// shared.rs
pub const SPEED: f64 = 9.0; // 가로 최대 칸/초
pub const CLIMB: f64 = 5.0; // 위아래
pub const CEILING: f64 = 40.0; // 발의 최고 높이
pub const SLACK: f64 = 1.35; // 서버의 여유 (늦은 보고, 긴 프레임)
pub fn velocity(ahead: f64, up: f64, yaw: f64) -> (f64, f64, f64) {
(yaw.sin() * ahead * SPEED, up * CLIMB, yaw.cos() * ahead * SPEED)
}
pub fn allowed(from: (f64, f64), to: (f64, f64, f64), dt: f64) -> Result<(), &'static str> {
if (to.0 - from.0).hypot(to.2 - from.1) > SPEED * SLACK * dt.max(0.0) + 0.5 { return Err("너무 빨리 날았어요") }
if to.1 > CEILING { return Err("너무 높이 날았어요") }
Ok(())
}
서버의 이동 검사
서버는 기본으로 다섯 가지를 검사합니다. 새 이동 방식이 그중 하나에 걸리면(날면 air, 빨리 날면 speed, 지붕 위로 날면 walls) 그것을 끕니다.
| 검사 | 막는 것 |
|---|---|
speed | 빠르기 (걸음, 행동 사이의 이동 시간) |
walls | 벽·건물 통과 (그리고 돌아가야 하는 길이) |
air | 공중·물 위·땅속에 2초 넘게 |
teleport | 걷기가 시작될 수 있는 곳(집 앞, 침대, 정류장)에서 먼 데 나타나기, 다른 사람에게 순간이동으로 보이기 |
reach | 도구의 칸이 손 닿는 거리 밖 |
- 모두에게:
Enable에서Registry::set("checks.air", "false"). 서버가 켜질 때 꺼진 검사가 로그에 한 줄로 나옵니다. - 한 사람에게만:
player.set_check("air", false)/set_check("air", true). 저장되지 않으니 필요하면Join에서 다시 부릅니다. - 위치 보고의 검사와 행동 사이 이동 시간의 검사가 같은 스위치를 따르므로 둘이 어긋나지 않습니다.
검사를 끄면 그만큼 부정 이동이 열립니다. 끈 검사는 반드시 PlayerMove에서 shared.rs의 규칙으로 다시 막으세요 (최대 빠르기, 최고 높이 …). 모두에게보다 한 사람에게만 끄고, 그 이동이 끝나면(착지) 다시 켜세요. plugins/flight는 땅에 닿았을 때, 늦어도 내려오기 시작하고 3초 뒤에 다시 켭니다.
PlayerMove로 판정하기
PlayerMove는 플레이어가 가로나 위아래로 움직일 때마다 옵니다(서버가 걷기를 검사하는 0.19초마다 한 번까지: 플레이어당 초당 약 5번). cancel이면 있던 곳으로 되돌려 보냅니다.
from() → (x, z) / from_y() | 지난 걸음의 자리와 발 높이 |
to() → (x, y, z) | 이번 자리 (y는 발) |
dt() | 지난 걸음부터의 초 |
on_ground() | 서버가 본 발밑에 무언가 있는지 (땅, 지붕, 갑판) |
mode() | 게임 쪽이 알린 이동 모드 (Me::set_move_mode; "" 게임 그대로). 주장일 뿐: 믿지 말고 판단의 근거로만 |
mount() | 탄 엔티티 (0 없음) |
// server.rs
#[on(Packet)]
fn asked(e: Packet) { // Y: 날기 / 내려오기
if e.channel() != TOGGLE { return }
let p = e.player();
let on = p.get("flying") == 0.0;
for c in ["air", "speed", "walls"] { p.set_check(c, !on) }
p.set("flying", on as i64 as f64);
p.hud().send_text(FLYING, if on { "1" } else { "0" });
}
#[on(PlayerMove)]
fn stepped(e: PlayerMove) {
if e.player().get("flying") != 0.0 {
if let Err(why) = allowed(e.from(), e.to(), e.dt()) { e.cancel(why) }
}
}
(실제 샘플은 내려올 때 바로 검사를 켜지 않고, 땅에 닿거나 3초가 지날 때까지 같은 규칙으로 판정합니다.)
비용: plugins/flight의 PlayerMove 핸들러 한 번에 약 5 µs(초당 30번이면 플레이어 한 명에 0.15 ms/초). 야생·땅의 아이템·방을 읽는 함수까지 가져다 쓰는 플러그인은 이벤트마다 그 사본을 만들어 약 100 µs가 됩니다. 걸음마다 오는 이벤트이니 핸들러를 가볍게, 읽기 함수는 꼭 필요한 것만 쓰세요.
게임 쪽에서 조작 넘겨받기
Input::capture(&["W", "A", "S", "D", "Space", "Shift"]) | 그 키를 게임에서 가져옴: 게임은 그 키로 아무것도 안 하고(WASD면 걷기, Space면 점프, Mouse1/Mouse2면 도구가 꺼짐) 이 플러그인의 input으로만 옴 |
Input::release() | 모든 키를 게임에 돌려줌 |
Input::held(key) | 지금 누르고 있는지 (가져간 키도) |
Me::set_position(x, y, z) | 지금 그 자리로 (칸; 이 게임의 예측) |
Me::set_yaw(yaw) | 그쪽으로 돌림 (라디안, 0이 +z) |
Me::set_velocity(x, y, z) | 게임의 물리로 그 속도로 움직임 (칸/초; 벽과 바닥이 막음), release_drive까지 게임의 걷기는 꺼짐 |
Me::release_drive() | 게임의 걷기를 되돌림 |
Me::set_gravity(on) | 중력 (끄면 놓인 곳에 머묾: 공중에도) |
Me::set_jump(on) | 점프 |
Me::set_move_mode("fly") | 서버에 알리는 이동 모드 (서버 쪽 PlayerMove::mode(); "" 게임 그대로) |
Me::velocity() / Me::on_ground() | 게임이 움직이는 속도, 발이 땅에 닿았는지 |
서버로 가는 위치 보고와 판정은 그대로입니다. 서버가 막은 걸음(검사나 PlayerMove의 cancel)은 되돌려지는데, 서버는 마지막으로 받아들인 곳의 높이도 함께 보내므로({"t":"back","y"}) 날던 사람은 땅바닥이 아니라 그 높이로 돌아갑니다.
카메라는 Camera::set(매 프레임 위치·바라볼 곳) follow orbit first_person mouse_look yaw release(게임 쪽 코드 › Camera). 플러그인이 내려놓거나 서버를 떠나면 키·이동·중력·점프·카메라가 모두 게임으로 돌아옵니다.
// client.rs
fn heard(p: Packet) {
if p.channel() != FLYING { return }
if p.data() == "1" {
Input::capture(&["W", "A", "S", "D", "Space", "Shift"]);
Me::set_gravity(false);
Me::set_move_mode("fly");
Camera::follow(0.0, 2.2, -5.5, 1.0, true);
unsafe { (ON, YAW) = (true, Me::yaw()) };
} else {
Input::release();
Me::release_drive();
Me::set_gravity(true);
Me::set_move_mode("");
Camera::release(0.5);
unsafe { ON = false };
}
}
fn axis(plus: &str, minus: &str) -> f64 { (Input::held(plus) as i64 - Input::held(minus) as i64) as f64 }
fn frame(f: Frame) {
if !unsafe { ON } { return }
let yaw = unsafe { YAW } + axis("A", "D") * TURN * f.dt();
unsafe { YAW = yaw };
Me::set_yaw(yaw);
let (vx, vy, vz) = velocity(axis("W", "S"), axis("Space", "Shift"), yaw); // shared.rs의 규칙
Me::set_velocity(vx, vy, vz);
}
탈것
서버 쪽 Player::mount(&엔티티, 빠르기, 앉는 높이) / dismount() — 그 사람이 한 일의 이벤트에서(엔티티를 E로 쓴 EntityInteract, 명령어, 버튼, Packet).
- 탄 동안 모든 게임이 그 엔티티를 탄 사람 아래에 그리고(카메라가 흐리게 하지 않음) 탄 사람을 앉힙니다. 나중에 들어온 사람도 누가 무엇을 탔는지 받습니다.
- 걸음은
빠르기배로 서버가 판정합니다(set_walk_speed와 같은 길). 그래서 탈것에는 검사를 끌 필요가 없습니다. - 내리면 엔티티는 그 자리에 남고 걸음은 전과 같아집니다. 저장되지 않습니다(다시 접속하면 걸어서).
- 서버가 내려 주기도 합니다: 건물 문으로 들어감(방 안에는 탈것 없음), 다른 월드로 감(
Player::move_world), 서버를 나감. 그때Dismount이벤트가 옵니다(reason()"door""world""quit",mount()). 플러그인이 부른dismount()에는 오지 않습니다.
#[on(EntityInteract)]
fn ride(e: EntityInteract) {
e.player().mount(&e.entity(), 2.0, 0.55); // 두 배 빠르게, 0.55 m 위에 앉음
e.entity().set_interact(""); // 그동안 다른 사람은 못 탐
}
#[on(Dismount)]
fn taken_off(e: Dismount) {
e.mount().set_interact("소 타기"); // 문으로 들어가 내렸으면 다시 탈 수 있게
}
도구 동작 (플러그인 아이템)
등록하는 아이템에 이 필드를 주면, 쓸 때 게임이 그 동작을 하고(다른 사람에게도 보임) 그 순간 Use 이벤트를 보냅니다.
| 필드 | |
|---|---|
"usable": true | 쓸 수 있는 아이템 |
"model" | 손에 드는 모델 res://plugins/<이름>/x.glb (자기 재질 그대로; 다른 사람의 아바타에도) |
"action" | 동작: cast 시전, attack thrust 공격, block 막기, cheer 환호, slash chop swing sweep throw 도구들의 동작, emote:<id> 플러그인의 감정 표현, 또는 클립의 res:// 경로 |
"hit" | 동작의 몇 할에서 쓰이는지 (기본 0.5): 이때 Use |
서버 쪽은 결과를 정하고(World::strike, give), 게임 쪽은 연출을 그립니다(Local, Camera::shake). 서버가 그 아이템을 알도록 값도 함께 등록하세요(Registry::set("price.<id>", ..)).
// server.rs
#[on(Enable)]
fn enable(_e: Enable) {
Registry::set("price.magic_staff", "300");
Registry::item("magic_staff", r#"{"name": "마법 지팡이", "usable": true, "type": "misc",
"icon": "res://plugins/riding/staff.png", "model": "res://plugins/riding/staff.glb", "action": "cast", "hit": 0.45}"#);
}
#[on(Use)]
fn cast(e: Use) {
if e.id() != "magic_staff" { return }
let p = e.player();
let ((x, z), (fx, fz)) = (p.position(), p.facing());
let (tx, tz) = (x + fx * 7.0, z + fz * 7.0);
e.world().strike(tx, tz, 1.8, 12); // 결과: 서버
p.hud().send_text("bolt", &format!("{x:.2},{z:.2},{tx:.2},{tz:.2}")); // 연출: 게임 쪽에
}
// client.rs: 구슬이 손에서 날아감 (이 플레이어에게만)
let id = Local::spawn("res://plugins/riding/orb.png", v[0], y, v[1]);
Local::glide_to(id, v[2], y, v[3], 0.35);
예제
plugins/flight비행: Y로 날기 시작하고 다시 Y로 내려옴. W/S 앞뒤, A/D 돌기, Space 위로, Shift 아래로. 규칙은shared.rs하나, 나는 동안 그 사람의airspeedwalls검사를 끄고PlayerMove에서 그 규칙으로 판정.plugins/riding탈것과 지팡이: 마을 소에 E로 올라타면 두 배 빨리 달림(서버가 그 빠르기로 판정), J로 내림,/소로 옆에 부름./지팡이로 받는 마법 지팡이를 쓰면 시전 동작과 함께 앞쪽에 번개 구슬이 날아가 몬스터를 침.