Brücke zu Godot und Godot-Projekt
Die GDExtension grovewright-bridge gibt den Kern als Klasse GwSession an GDScript weiter: Befehle einreihen, einen Takt ausführen, Karte und Inventar lesen, speichern, laden, aufzeichnen. Eine Panik im Kern macht die Sitzung defekt, statt einen guten Speicherstand zu überschreiben. Das Godot-Projekt bekommt einen Testläufer, der auch Laufzeitfehler als Fehlschlag zählt. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
//! Turns events of the core into dictionaries that GDScript can read.
|
||||
|
||||
use godot::prelude::*;
|
||||
use grovewright_core::world::Event;
|
||||
|
||||
/// One event as a dictionary with a `type` entry and the event's fields.
|
||||
pub fn to_dictionary(event: &Event) -> VarDictionary {
|
||||
match event {
|
||||
Event::PlayerJoined { player } => vdict! {
|
||||
"type" => "player_joined",
|
||||
"player" => i64::from(player.0),
|
||||
},
|
||||
Event::PlayerLeft { player } => vdict! {
|
||||
"type" => "player_left",
|
||||
"player" => i64::from(player.0),
|
||||
},
|
||||
Event::JoinRefused { .. } => vdict! {
|
||||
"type" => "join_refused",
|
||||
},
|
||||
Event::TileHit { player, pos } => vdict! {
|
||||
"type" => "tile_hit",
|
||||
"player" => i64::from(player.0),
|
||||
"x" => i64::from(pos.x),
|
||||
"y" => i64::from(pos.y),
|
||||
},
|
||||
Event::TileChanged { pos } => vdict! {
|
||||
"type" => "tile_changed",
|
||||
"x" => i64::from(pos.x),
|
||||
"y" => i64::from(pos.y),
|
||||
},
|
||||
Event::ItemGained {
|
||||
player,
|
||||
item,
|
||||
count,
|
||||
} => vdict! {
|
||||
"type" => "item_gained",
|
||||
"player" => i64::from(player.0),
|
||||
"item" => i64::from(item.0),
|
||||
"count" => i64::from(*count),
|
||||
},
|
||||
Event::InventoryFull { player } => vdict! {
|
||||
"type" => "inventory_full",
|
||||
"player" => i64::from(player.0),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! GDExtension that makes the deterministic core callable from Godot.
|
||||
//!
|
||||
//! The bridge only translates. Every rule lives in `grovewright-core`, and
|
||||
//! everything that is shown, heard or typed lives in the Godot project.
|
||||
|
||||
mod events;
|
||||
mod session;
|
||||
|
||||
use godot::prelude::*;
|
||||
|
||||
struct GrovewrightExtension;
|
||||
|
||||
// SAFETY: godot-rust requires this marker to register the extension's entry
|
||||
// point. The implementation keeps every default and adds no code.
|
||||
#[gdextension]
|
||||
unsafe impl ExtensionLibrary for GrovewrightExtension {}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! A solo game session for GDScript: the shared world, its recording and the
|
||||
//! commands waiting for the next tick.
|
||||
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
|
||||
use godot::prelude::*;
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::inventory::ItemId;
|
||||
use grovewright_core::map::{Pos, Terrain};
|
||||
use grovewright_core::mapgen::spawn_pos;
|
||||
use grovewright_core::player::{PlayerId, PlayerKey};
|
||||
use grovewright_core::replay::Recording;
|
||||
use grovewright_core::world::World;
|
||||
|
||||
use crate::events;
|
||||
|
||||
/// Everything a running session holds.
|
||||
struct Game {
|
||||
world: World,
|
||||
recording: Recording,
|
||||
/// Commands for the next tick, in the order they were queued.
|
||||
pending: Vec<Command>,
|
||||
local_key: Option<PlayerKey>,
|
||||
local: Option<PlayerId>,
|
||||
/// Set when the core panicked. The world may be half-updated, so the
|
||||
/// session neither steps nor saves any more.
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
fn new(world: World) -> Self {
|
||||
let recording = Recording::start(&world);
|
||||
Self {
|
||||
world,
|
||||
recording,
|
||||
pending: Vec::new(),
|
||||
local_key: None,
|
||||
local: None,
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_player(&self) -> Option<&grovewright_core::player::Player> {
|
||||
self.local.and_then(|id| self.world.player(id))
|
||||
}
|
||||
}
|
||||
|
||||
/// A solo session. GDScript queues commands, calls [`GwSession::step`] once
|
||||
/// per tick and reads the world back for display.
|
||||
#[derive(GodotClass)]
|
||||
#[class(base = RefCounted, init)]
|
||||
pub struct GwSession {
|
||||
game: Option<Game>,
|
||||
last_error: GString,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl GwSession {
|
||||
/// Simulation ticks per second.
|
||||
#[constant]
|
||||
const TICKS_PER_SECOND: i64 = grovewright_core::world::TICKS_PER_SECOND as i64;
|
||||
/// Ticks a player waits between two pickaxe hits.
|
||||
#[constant]
|
||||
const DIG_COOLDOWN_TICKS: i64 = grovewright_core::world::DIG_COOLDOWN_TICKS as i64;
|
||||
#[constant]
|
||||
const TERRAIN_GROUND: i64 = Terrain::Ground as i64;
|
||||
#[constant]
|
||||
const TERRAIN_SOFT_ROCK: i64 = Terrain::SoftRock as i64;
|
||||
#[constant]
|
||||
const TERRAIN_HARD_ROCK: i64 = Terrain::HardRock as i64;
|
||||
#[constant]
|
||||
const TERRAIN_BEDROCK: i64 = Terrain::Bedrock as i64;
|
||||
#[constant]
|
||||
const ITEM_STONE: i64 = grovewright_core::inventory::ITEM_STONE.0 as i64;
|
||||
|
||||
/// Starts a fresh world from `seed`, replacing any running game.
|
||||
#[func]
|
||||
fn start_new(&mut self, seed: i64) {
|
||||
self.game = Some(Game::new(World::new(seed as u64)));
|
||||
self.last_error = GString::new();
|
||||
}
|
||||
|
||||
/// Starts from a save. Returns false and keeps any running game if the
|
||||
/// save cannot be used; [`GwSession::last_error`] then says why.
|
||||
#[func]
|
||||
fn start_loaded(&mut self, save: PackedByteArray) -> bool {
|
||||
match World::load(save.as_slice()) {
|
||||
Ok(world) => {
|
||||
self.game = Some(Game::new(world));
|
||||
self.last_error = GString::new();
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
self.last_error = GString::from(error.to_string().as_str());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the last call failed, or an empty text.
|
||||
#[func]
|
||||
fn last_error(&self) -> GString {
|
||||
self.last_error.clone()
|
||||
}
|
||||
|
||||
#[func]
|
||||
fn is_running(&self) -> bool {
|
||||
self.game.is_some()
|
||||
}
|
||||
|
||||
/// True after the core stopped with an internal error.
|
||||
#[func]
|
||||
fn is_broken(&self) -> bool {
|
||||
self.game.as_ref().is_some_and(|game| game.broken)
|
||||
}
|
||||
|
||||
/// Queues the entry of the local player for the next tick. `key`
|
||||
/// identifies the player across sessions and must have 16 bytes.
|
||||
#[func]
|
||||
fn join(&mut self, key: PackedByteArray, name: GString) -> bool {
|
||||
let Some(game) = self.game.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(key) = PlayerKey::try_from(key.as_slice()) else {
|
||||
self.last_error = GString::from("a player key has 16 bytes");
|
||||
return false;
|
||||
};
|
||||
game.local_key = Some(key);
|
||||
game.local = None;
|
||||
game.pending.push(Command::Join {
|
||||
key,
|
||||
name: name.to_string(),
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Queues a pickaxe hit of the local player on tile (`x`, `y`) for the
|
||||
/// next tick. Does nothing before the local player has joined.
|
||||
#[func]
|
||||
fn dig(&mut self, x: i64, y: i64) {
|
||||
let Some(game) = self.game.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let (Some(player), Some(pos)) = (game.local, to_pos(x, y)) else {
|
||||
return;
|
||||
};
|
||||
game.pending.push(Command::Dig { player, pos });
|
||||
}
|
||||
|
||||
/// Runs one tick with the queued commands and returns what happened, as
|
||||
/// dictionaries with a `type` entry.
|
||||
#[func]
|
||||
fn step(&mut self) -> Array<VarDictionary> {
|
||||
let mut happened = Array::new();
|
||||
let Some(game) = self.game.as_mut() else {
|
||||
return happened;
|
||||
};
|
||||
if game.broken {
|
||||
return happened;
|
||||
}
|
||||
let commands = std::mem::take(&mut game.pending);
|
||||
let Game {
|
||||
world, recording, ..
|
||||
} = game;
|
||||
match catch_unwind(AssertUnwindSafe(|| recording.step(world, &commands))) {
|
||||
Ok(events) => {
|
||||
for event in &events {
|
||||
happened.push(&events::to_dictionary(event));
|
||||
}
|
||||
if game.local.is_none() {
|
||||
game.local = game.local_key.and_then(|key| game.world.find_player(&key));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
game.broken = true;
|
||||
self.last_error = GString::from("the simulation stopped after an internal error");
|
||||
}
|
||||
}
|
||||
happened
|
||||
}
|
||||
|
||||
/// Number of ticks the world has run.
|
||||
#[func]
|
||||
fn tick(&self) -> i64 {
|
||||
self.game
|
||||
.as_ref()
|
||||
.map_or(0, |game| game.world.tick() as i64)
|
||||
}
|
||||
|
||||
#[func]
|
||||
fn map_width(&self) -> i64 {
|
||||
self.game
|
||||
.as_ref()
|
||||
.map_or(0, |game| i64::from(game.world.map().width()))
|
||||
}
|
||||
|
||||
#[func]
|
||||
fn map_height(&self) -> i64 {
|
||||
self.game
|
||||
.as_ref()
|
||||
.map_or(0, |game| i64::from(game.world.map().height()))
|
||||
}
|
||||
|
||||
/// The terrain of every tile, row by row (`TERRAIN_*` constants).
|
||||
#[func]
|
||||
fn terrain_codes(&self) -> PackedByteArray {
|
||||
self.tile_bytes(|terrain, _| terrain as u8)
|
||||
}
|
||||
|
||||
/// The hits every tile has taken, row by row.
|
||||
#[func]
|
||||
fn damage_codes(&self) -> PackedByteArray {
|
||||
self.tile_bytes(|_, damage| damage)
|
||||
}
|
||||
|
||||
/// The terrain of tile (`x`, `y`), or -1 outside the map.
|
||||
#[func]
|
||||
fn terrain_at(&self, x: i64, y: i64) -> i64 {
|
||||
self.tile(x, y).map_or(-1, |(terrain, _)| terrain as i64)
|
||||
}
|
||||
|
||||
/// Whether a figure can stand on tile (`x`, `y`).
|
||||
#[func]
|
||||
fn is_walkable(&self, x: i64, y: i64) -> bool {
|
||||
self.tile(x, y)
|
||||
.is_some_and(|(terrain, _)| terrain.is_walkable())
|
||||
}
|
||||
|
||||
/// The tile where figures appear.
|
||||
#[func]
|
||||
fn spawn_point(&self) -> Vector2i {
|
||||
self.game.as_ref().map_or(Vector2i::ZERO, |game| {
|
||||
let spawn = spawn_pos(game.world.map());
|
||||
Vector2i::new(spawn.x, spawn.y)
|
||||
})
|
||||
}
|
||||
|
||||
/// Index of the local player in the world, or -1 before joining.
|
||||
#[func]
|
||||
fn local_player(&self) -> i64 {
|
||||
self.game
|
||||
.as_ref()
|
||||
.and_then(|game| game.local)
|
||||
.map_or(-1, |id| i64::from(id.0))
|
||||
}
|
||||
|
||||
/// How many of `item` the local player carries.
|
||||
#[func]
|
||||
fn item_count(&self, item: i64) -> i64 {
|
||||
let (Some(game), Ok(item)) = (self.game.as_ref(), u16::try_from(item)) else {
|
||||
return 0;
|
||||
};
|
||||
game.local_player()
|
||||
.map_or(0, |player| i64::from(player.inventory.count(ItemId(item))))
|
||||
}
|
||||
|
||||
/// The world as a save, or empty if there is no healthy session.
|
||||
#[func]
|
||||
fn save(&self) -> PackedByteArray {
|
||||
match self.game.as_ref() {
|
||||
Some(game) if !game.broken => {
|
||||
PackedByteArray::from(game.world.to_snapshot().as_slice())
|
||||
}
|
||||
_ => PackedByteArray::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The recording of this session, or empty if there is no healthy
|
||||
/// session.
|
||||
#[func]
|
||||
fn recording_bytes(&self) -> PackedByteArray {
|
||||
match self.game.as_ref() {
|
||||
Some(game) if !game.broken => {
|
||||
PackedByteArray::from(game.recording.to_bytes(&game.world).as_slice())
|
||||
}
|
||||
_ => PackedByteArray::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The checksum of the world state, as 16 hexadecimal digits.
|
||||
#[func]
|
||||
fn state_hash_hex(&self) -> GString {
|
||||
self.game.as_ref().map_or_else(GString::new, |game| {
|
||||
GString::from(format!("{:016x}", game.world.state_hash()).as_str())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl GwSession {
|
||||
fn tile(&self, x: i64, y: i64) -> Option<(Terrain, u8)> {
|
||||
let game = self.game.as_ref()?;
|
||||
let tile = game.world.map().get(to_pos(x, y)?)?;
|
||||
Some((tile.terrain, tile.damage))
|
||||
}
|
||||
|
||||
fn tile_bytes(&self, byte: impl Fn(Terrain, u8) -> u8) -> PackedByteArray {
|
||||
let Some(game) = self.game.as_ref() else {
|
||||
return PackedByteArray::new();
|
||||
};
|
||||
let bytes: Vec<u8> = game
|
||||
.world
|
||||
.map()
|
||||
.tiles()
|
||||
.iter()
|
||||
.map(|tile| byte(tile.terrain, tile.damage))
|
||||
.collect();
|
||||
PackedByteArray::from(bytes.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
/// A tile position from GDScript integers, or `None` if it cannot exist.
|
||||
fn to_pos(x: i64, y: i64) -> Option<Pos> {
|
||||
Some(Pos::new(i32::try_from(x).ok()?, i32::try_from(y).ok()?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn positions_outside_the_i32_range_are_refused() {
|
||||
assert_eq!(to_pos(3, -4), Some(Pos::new(3, -4)));
|
||||
assert_eq!(to_pos(i64::from(i32::MAX) + 1, 0), None);
|
||||
assert_eq!(to_pos(0, i64::from(i32::MIN) - 1), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user