use bevy::prelude::*;
#[cfg(target_os = "ios")]
mod backend {
use std::ffi::c_char;
unsafe extern "C" {
pub fn gamekit_authenticate();
pub fn gamekit_auth_state() -> i32;
pub fn gamekit_submit_score(leaderboard_id: *const c_char, score: i64);
pub fn gamekit_report_achievement(achievement_id: *const c_char, percent: f64);
pub fn gamekit_show_dashboard();
}
}
#[cfg(not(target_os = "ios"))]
mod backend {
use std::ffi::{CStr, c_char};
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::{LazyLock, Mutex};
static STATE: AtomicI32 = AtomicI32::new(0);
static SCORES: LazyLock<Mutex<Vec<(String, i64)>>> = LazyLock::new(|| Mutex::new(Vec::new()));
static ACHIEVEMENTS: LazyLock<Mutex<Vec<(String, f64)>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
unsafe fn cstr(ptr: *const c_char) -> String {
if ptr.is_null() {
return String::new();
}
unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned()
}
pub unsafe fn gamekit_authenticate() {
let unavailable = std::env::var("BEVY_IOS_FAKE_GAMECENTER")
.map(|v| v.eq_ignore_ascii_case("unavailable"))
.unwrap_or(false);
STATE.store(if unavailable { 3 } else { 2 }, Ordering::SeqCst);
}
pub unsafe fn gamekit_auth_state() -> i32 {
STATE.load(Ordering::SeqCst)
}
pub unsafe fn gamekit_submit_score(leaderboard_id: *const c_char, score: i64) {
let id = unsafe { cstr(leaderboard_id) };
SCORES
.lock()
.unwrap_or_else(|p| p.into_inner())
.push((id, score));
}
pub unsafe fn gamekit_report_achievement(achievement_id: *const c_char, percent: f64) {
let id = unsafe { cstr(achievement_id) };
ACHIEVEMENTS
.lock()
.unwrap_or_else(|p| p.into_inner())
.push((id, percent));
}
pub unsafe fn gamekit_show_dashboard() {}
#[cfg(test)]
pub fn reset() {
STATE.store(0, Ordering::SeqCst);
SCORES.lock().unwrap_or_else(|p| p.into_inner()).clear();
ACHIEVEMENTS
.lock()
.unwrap_or_else(|p| p.into_inner())
.clear();
}
#[cfg(test)]
pub fn submitted_scores() -> Vec<(String, i64)> {
SCORES.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
#[cfg(test)]
pub fn reported_achievements() -> Vec<(String, f64)> {
ACHIEVEMENTS
.lock()
.unwrap_or_else(|p| p.into_inner())
.clone()
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum GameCenterAuthState {
#[default]
Unknown,
Authenticating,
Authenticated,
Unavailable,
}
impl GameCenterAuthState {
fn from_i32(v: i32) -> GameCenterAuthState {
match v {
1 => GameCenterAuthState::Authenticating,
2 => GameCenterAuthState::Authenticated,
3 => GameCenterAuthState::Unavailable,
_ => GameCenterAuthState::Unknown,
}
}
}
#[derive(Resource, Default)]
pub struct GameCenter {
pub auth: GameCenterAuthState,
}
impl GameCenter {
pub fn is_authenticated(&self) -> bool {
self.auth == GameCenterAuthState::Authenticated
}
}
#[derive(Message, Clone, Debug)]
pub struct AuthenticateGameCenter;
#[derive(Message, Clone, Debug)]
pub struct SubmitScore {
pub leaderboard_id: String,
pub score: i64,
}
#[derive(Message, Clone, Debug)]
pub struct ReportAchievement {
pub achievement_id: String,
pub percent: f64,
}
#[derive(Message, Clone, Debug)]
pub struct ShowGameCenter;
#[derive(Message, Clone, Debug)]
pub struct GameCenterAuthChanged(pub GameCenterAuthState);
pub struct GameKitPlugin;
impl Plugin for GameKitPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GameCenter>()
.add_message::<AuthenticateGameCenter>()
.add_message::<SubmitScore>()
.add_message::<ReportAchievement>()
.add_message::<ShowGameCenter>()
.add_message::<GameCenterAuthChanged>()
.add_systems(Update, (pump_requests, poll_auth).chain());
}
}
fn pump_requests(
gc: Res<GameCenter>,
mut auths: MessageReader<AuthenticateGameCenter>,
mut scores: MessageReader<SubmitScore>,
mut achievements: MessageReader<ReportAchievement>,
mut dashboards: MessageReader<ShowGameCenter>,
) {
for _ in auths.read() {
unsafe { backend::gamekit_authenticate() };
}
for s in scores.read() {
if gc.is_authenticated()
&& let Ok(id) = std::ffi::CString::new(s.leaderboard_id.as_str())
{
unsafe { backend::gamekit_submit_score(id.as_ptr(), s.score) };
}
}
for a in achievements.read() {
if gc.is_authenticated()
&& let Ok(id) = std::ffi::CString::new(a.achievement_id.as_str())
{
unsafe {
backend::gamekit_report_achievement(id.as_ptr(), a.percent.clamp(0.0, 100.0))
};
}
}
for _ in dashboards.read() {
unsafe { backend::gamekit_show_dashboard() };
}
}
fn poll_auth(mut gc: ResMut<GameCenter>, mut changed: MessageWriter<GameCenterAuthChanged>) {
let current = GameCenterAuthState::from_i32(unsafe { backend::gamekit_auth_state() });
if current != gc.auth {
gc.auth = current;
changed.write(GameCenterAuthChanged(current));
}
}
#[cfg(test)]
mod tests {
use super::*;
static GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn guarded() -> std::sync::MutexGuard<'static, ()> {
let g = GUARD.lock().unwrap_or_else(|p| p.into_inner());
backend::reset();
unsafe { std::env::remove_var("BEVY_IOS_FAKE_GAMECENTER") };
g
}
fn build() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(GameKitPlugin);
app
}
#[test]
fn authenticate_then_submit_records() {
let _guard = guarded();
let mut app = build();
app.world_mut()
.resource_mut::<Messages<AuthenticateGameCenter>>()
.write(AuthenticateGameCenter);
app.update(); app.update(); assert!(app.world().resource::<GameCenter>().is_authenticated());
app.world_mut()
.resource_mut::<Messages<SubmitScore>>()
.write(SubmitScore {
leaderboard_id: "lb.highscore".into(),
score: 4200,
});
app.world_mut()
.resource_mut::<Messages<ReportAchievement>>()
.write(ReportAchievement {
achievement_id: "ach.first_win".into(),
percent: 100.0,
});
app.update();
assert_eq!(
backend::submitted_scores(),
vec![("lb.highscore".to_string(), 4200)]
);
assert_eq!(
backend::reported_achievements(),
vec![("ach.first_win".to_string(), 100.0)]
);
}
#[test]
fn submit_dropped_when_unavailable() {
let _guard = guarded();
unsafe { std::env::set_var("BEVY_IOS_FAKE_GAMECENTER", "unavailable") };
let mut app = build();
app.world_mut()
.resource_mut::<Messages<AuthenticateGameCenter>>()
.write(AuthenticateGameCenter);
app.update();
app.update();
assert_eq!(
app.world().resource::<GameCenter>().auth,
GameCenterAuthState::Unavailable
);
app.world_mut()
.resource_mut::<Messages<SubmitScore>>()
.write(SubmitScore {
leaderboard_id: "lb.highscore".into(),
score: 10,
});
app.update();
assert!(
backend::submitted_scores().is_empty(),
"no submit while unauthenticated"
);
unsafe { std::env::remove_var("BEVY_IOS_FAKE_GAMECENTER") };
}
}