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:
Steven Wroblewski
2026-10-01 10:46:23 +02:00
parent 5dacbf2222
commit bd0f588714
16 changed files with 838 additions and 1 deletions
+46
View File
@@ -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),
},
}
}
+16
View File
@@ -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 {}
+326
View File
@@ -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);
}
}