// Pong: a DrawnGame with a game loop, sprites moved by Left / Top, an AI paddle, keyboard and touch
// input. The 360 x 640 field is fitted to the canvas by a RescalingLayout (rendering scale, not a
// transform). The same game as the DrawnUI Pong sample for .NET and React.
use std::f32::consts::PI;
use drawnui::prelude::*;
/// A color from 0xRRGGBB.
const fn hex(rgb: u32) -> Color {
Color::new(0xFF00_0000 | rgb)
}
const WIDTH: f32 = 360.0;
const HEIGHT: f32 = 640.0;
const PADDLE_SPEED: f32 = 420.0;
const PADDLE_MARGIN: f32 = 40.0;
const WIN_SCORE: u32 = 7;
const BALL_SIZE: f32 = 14.0;
const PADDLE_WIDTH: f32 = 80.0;
const PADDLE_HEIGHT: f32 = 16.0;
const BALL_SPEED: f32 = 300.0;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
enum Phase {
#[default]
WaitingToStart,
Playing,
Scored,
GameOver,
}
/// A collision rect in field space (GameExtensions.GetHitBox): Left / Top and the sprite's size.
#[derive(Clone, Copy, Default)]
struct HitBox {
left: f32,
top: f32,
right: f32,
bottom: f32,
}
impl HitBox {
fn of(left: f32, top: f32, width: f32, height: f32) -> Self {
Self { left, top, right: left + width, bottom: top + height }
}
/// SkiaSharp SKRect.IntersectsWith.
fn intersects(&self, other: &HitBox) -> bool {
self.left < other.right && other.left < self.right && self.top < other.bottom && other.top < self.bottom
}
fn mid_x(&self) -> f32 {
(self.left + self.right) / 2.0
}
fn mid_y(&self) -> f32 {
(self.top + self.bottom) / 2.0
}
fn width(&self) -> f32 {
self.right - self.left
}
}
/// The ball (BallSprite): where it is, where it goes, and the oscillation guard that unsticks a
/// ball bouncing between the same two angles.
#[derive(Default)]
struct Ball {
left: f32,
top: f32,
angle: f32,
speed: f32,
moving: bool,
history: [Option<f32>; 3],
oscillations: u32,
}
impl Ball {
fn hit_box(&self) -> HitBox {
HitBox::of(self.left, self.top, BALL_SIZE, BALL_SIZE)
}
fn set_angle(&mut self, angle: f32, random: &mut Random) {
self.angle = clamp_angle_from_horizontal(angle);
if !self.moving {
self.history = [None; 3];
self.oscillations = 0;
return;
}
self.history = [Some(self.angle), self.history[0], self.history[1]];
let [Some(h1), Some(h2), Some(h3)] = self.history else { return };
let tolerance = 0.01;
if (h1 - h3).abs() < tolerance && (h2 - h1).abs() > tolerance {
self.oscillations += 1;
if self.oscillations >= 6 {
let nudge = (random.next() - 0.5) * 0.4;
self.angle = clamp_angle_from_horizontal(self.angle + nudge);
self.history = [None; 3];
self.oscillations = 0;
}
} else {
self.oscillations = 0;
}
}
fn update_position(&mut self, delta: f32) {
if delta <= 0.0 || !self.moving {
return;
}
self.left += self.speed * self.angle.cos() * delta;
self.top += self.speed * self.angle.sin() * delta;
}
}
/// Keeps the ball from flying (almost) horizontally: at least PI / 10 away from 0 and PI.
fn clamp_angle_from_horizontal(angle: f32) -> f32 {
let min = PI / 10.0;
let two_pi = 2.0 * PI;
let mut normalized = angle % two_pi;
if normalized <= -PI {
normalized += two_pi;
} else if normalized > PI {
normalized -= two_pi;
}
let near_zero = normalized.abs() < min;
let near_pi = normalized.abs() > PI - min;
if !near_zero && !near_pi {
return normalized;
}
let sign = if normalized < 0.0 { -1.0 } else { 1.0 };
if near_zero { sign * min } else { sign * (PI - min) }
}
/// Math.random for the game: xorshift, seeded from the clock.
struct Random(u64);
impl Default for Random {
fn default() -> Self {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos() as u64);
Self(nanos | 1)
}
}
impl Random {
/// 0 <= x < 1.
fn next(&mut self) -> f32 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 >> 40) as f32 / (1u64 << 24) as f32
}
}
/// The AI of PongAI.cs at Medium difficulty.
#[derive(Default)]
struct Ai {
target_x: f32,
reaction_timer: f32,
mistake_timer: f32,
decision_change_timer: f32,
decision_change_interval: f32,
movement_smoothing_timer: f32,
making_mistake: bool,
mistake_direction: f32,
is_moving: bool,
last_movement: f32,
}
// Medium: reaction 0.06..0.22 s, accuracy 0.84, mistakes 10 % of decisions for 0.25..0.5 s,
// decisions every 1.8 s at first, movement smoothing 0.1 s.
const REACTION_MIN: f32 = 0.06;
const REACTION_MAX: f32 = 0.22;
const ACCURACY: f32 = 0.84;
const MISTAKE_PROBABILITY: f32 = 0.10;
const MISTAKE_MIN: f32 = 0.25;
const MISTAKE_MAX: f32 = 0.5;
const DECISION_INTERVAL: f32 = 1.8;
const SMOOTHING: f32 = 0.10;
/// The game state and the controls it moves.
#[derive(Default)]
struct App {
game: Handle<DrawnGame>,
ball_view: Handle<SkiaShape>,
player_view: Handle<SkiaShape>,
ai_view: Handle<SkiaShape>,
score_label: Handle<SkiaLabel>,
message_label: Handle<SkiaLabel>,
ball: Ball,
player_left: f32,
ai_left: f32,
player_score: u32,
ai_score: u32,
player_movement: f32,
ai_movement: f32,
phase: Phase,
phase_timer: f32,
ai_serves: bool,
auto_serve_timer: f32,
ai_wander_timer: f32,
ai_wander_dir: f32,
player_has_moved: bool,
last_scorer_player: bool,
ai: Ai,
random: Random,
}
fn build(page: &mut App) -> Build<SkiaLayout> {
page.player_left = (WIDTH - PADDLE_WIDTH) / 2.0;
page.ai_left = (WIDTH - PADDLE_WIDTH) / 2.0;
reset_ball(page, true);
page.ai.decision_change_interval = DECISION_INTERVAL;
reset_ai(page);
let ball = (page.ball.left, page.ball.top);
SkiaLayer::new().background_color(hex(0x0A0F1E)).fill().children((
SkiaLabel::new("โ โ or drag to move, tap / Space to serve ยท first to 7")
.font_family_fallback("FontSymbols,FontSymbols2")
.font_size(13)
.text_color(hex(0xADB5BD))
.horizontal_options(LayoutOptions::Center)
.margin((12, 8, 12, 0)),
SkiaLayer::new().fill().margin((0, 36, 0, 0)).children(RescalingLayout::new(WIDTH, HEIGHT).children(
DrawnGame::new()
.width_request(WIDTH)
.height_request(HEIGHT)
.horizontal_options(LayoutOptions::Center)
.vertical_options(LayoutOptions::Center)
// Colors.DarkGreen
.background_color(hex(0x006400))
.assign(&mut page.game)
.start_loop(0.0)
.on_game_loop(|_me, app: &mut App, cx, delta| game_loop(app, cx, delta))
.on_key_down(|_me, app: &mut App, cx, event| key_down(app, cx, event.key))
.on_key_up(|_me, app: &mut App, _cx, event| key_up(app, event.key))
// The field takes its presses: a pan steers the paddle, a tap serves.
.consume_gestures(|me, app: &mut App, cx, gesture| match gesture.kind {
GestureKind::Panning => {
let velocity = gesture.velocity.x / me.base().scale.max(0.1);
app.player_movement = if velocity.abs() > 5.0 { velocity.signum() } else { 0.0 };
true
}
GestureKind::Up => {
app.player_movement = 0.0;
true
}
GestureKind::Tapped => {
serve(app, cx, false);
false
}
_ => false,
})
.children((
SkiaShape::new()
.background_color(Color::TRANSPARENT)
.stroke_color(hex(0xFEFEFE))
.stroke_width(2)
.fill(),
paddle(0xFF2222).left(page.ai_left).top(PADDLE_MARGIN).assign(&mut page.ai_view),
paddle(0x4CC9F0).left(page.player_left).top(HEIGHT - PADDLE_MARGIN - PADDLE_HEIGHT).assign(&mut page.player_view),
SkiaShape::new()
.shape_type(ShapeType::Circle)
.height_request(BALL_SIZE)
.lock_ratio(1)
.stroke_color(Color::WHITE)
.stroke_width(2)
.background_color(hex(0xFFFF00))
.bevel_type(BevelType::Bevel)
.bevel(SkiaBevel::new(3).light_color(Color::WHITE).shadow_color(hex(0x333333)).opacity(0.33))
.use_cache(CacheType::Image)
.left(ball.0)
.top(ball.1)
.assign(&mut page.ball_view),
SkiaLabel::new("0 : 0")
.font_family("FontGame")
.font_size(28)
.text_color(Color::WHITE)
.horizontal_options(LayoutOptions::Center)
.margin((0.0, HEIGHT / 2.0 - 24.0, 0.0, 0.0))
.horizontal_text_alignment(TextAlignment::Center)
.assign(&mut page.score_label),
SkiaLabel::new("TAP TO SERVE")
.font_family("FontGame")
.font_size(14)
.text_color(Color::new(0xCCFFFFFF))
.horizontal_options(LayoutOptions::Center)
.margin((0.0, HEIGHT / 2.0 + 12.0, 0.0, 0.0))
.horizontal_text_alignment(TextAlignment::Center)
.assign(&mut page.message_label),
)),
)),
))
}
/// A paddle sprite: 80 x 16, rounded, with a bevel. Its own bitmap, moved by `left`.
fn paddle(color: u32) -> Build<SkiaShape> {
SkiaShape::new()
.height_request(PADDLE_HEIGHT)
.width_request(PADDLE_WIDTH)
.corner_radius(PADDLE_HEIGHT / 2.0)
.background_color(hex(color))
.stroke_color(hex(0xCCCCFF))
.stroke_width(2)
.bevel_type(BevelType::Bevel)
.bevel(SkiaBevel::new(4).light_color(Color::WHITE).shadow_color(hex(0x333333)).opacity(0.33))
.use_cache(CacheType::Image)
}
/// Window-level hooks. A hidden page gets no frames: the game pauses, and its frame clock restarts
/// when the page comes back, else the first frame would carry the whole hidden time.
fn configure(ui: drawnui::Ui<App>) -> drawnui::Ui<App> {
ui.on_visibility_changed(|app: &mut App, visible, cx| visibility(app, cx, visible))
}
fn set_text(cx: &mut Cx, label: Handle<SkiaLabel>, text: impl Into<String>) {
if let Some(mut label) = cx.get_mut(label) {
label.set_text(text.into());
}
}
fn visibility(app: &mut App, cx: &mut Cx, visible: bool) {
if let Some(mut game) = cx.get_mut(app.game) {
if visible { game.resume() } else { game.pause() }
}
}
fn key_down(app: &mut App, cx: &mut Cx, key: &str) -> bool {
match key {
"ArrowLeft" => app.player_movement = -1.0,
"ArrowRight" => app.player_movement = 1.0,
"Space" | "ArrowUp" | "ArrowDown" | "Enter" => serve(app, cx, false),
_ => return false,
}
true
}
fn key_up(app: &mut App, key: &str) -> bool {
let page = app;
match key {
"ArrowLeft" if page.player_movement < 0.0 => page.player_movement = 0.0,
"ArrowRight" if page.player_movement > 0.0 => page.player_movement = 0.0,
_ => return false,
}
true
}
fn reset_ball(page: &mut App, player_serves: bool) {
page.ball.left = (WIDTH - BALL_SIZE) / 2.0;
page.ball.top = if player_serves { HEIGHT - PADDLE_MARGIN - PADDLE_HEIGHT - BALL_SIZE } else { PADDLE_MARGIN + PADDLE_HEIGHT - 1.0 };
page.ball.moving = false;
let spread = (page.random.next() - 0.5) * 0.6;
let angle = if player_serves { PI / 2.0 + spread } else { -PI / 2.0 + spread };
page.ball.set_angle(angle, &mut page.random);
}
fn reset_paddles(page: &mut App) {
page.player_left = (WIDTH - PADDLE_WIDTH) / 2.0;
page.ai_left = (WIDTH - PADDLE_WIDTH) / 2.0;
}
fn set_message(cx: &mut Cx, page: &App, text: &str) {
set_text(cx, page.message_label, text);
}
fn update_score(cx: &mut Cx, page: &App) {
set_text(cx, page.score_label, format!("{} : {}", page.ai_score, page.player_score));
}
fn serve(app: &mut App, cx: &mut Cx, by_ai: bool) {
serve_page(app, cx, by_ai);
}
/// PongGame.Serve: the ball goes, or a finished game starts over.
fn serve_page(page: &mut App, cx: &mut Cx, by_ai: bool) {
if page.phase == Phase::WaitingToStart && (!page.ai_serves || by_ai) {
page.ball.moving = true;
page.ball.speed = BALL_SPEED;
page.phase = Phase::Playing;
set_message(cx, page, "");
reset_ai(page);
} else if page.phase == Phase::GameOver {
(page.player_score, page.ai_score) = (0, 0);
page.ai_serves = false;
page.player_has_moved = false;
page.ai_wander_dir = 0.0;
update_score(cx, page);
reset_ball(page, true);
reset_paddles(page);
page.phase = Phase::WaitingToStart;
set_message(cx, page, "TAP TO SERVE");
}
}
/// One frame of the game (PongGame.Loop.cs), then the sprites are moved to the new positions.
fn game_loop(app: &mut App, cx: &mut Cx, delta: f32) {
step(app, cx, delta);
let page = &*app;
for (view, left) in [(page.player_view, page.player_left), (page.ai_view, page.ai_left)] {
if let Some(mut paddle) = cx.get_mut(view) {
paddle.set_left(left);
}
}
if let Some(mut ball) = cx.get_mut(page.ball_view) {
ball.set_left(page.ball.left);
ball.set_top(page.ball.top);
}
}
fn step(page: &mut App, cx: &mut Cx, delta: f32) {
match page.phase {
Phase::WaitingToStart => {
if page.ai_serves {
page.auto_serve_timer -= delta;
wander(page, delta, 0.25, 0.25, 0.35);
page.ai_left = move_paddle(page.ai_left, page.ai_wander_dir, delta);
page.player_left = move_paddle(page.player_left, page.player_movement, delta);
page.ball.left = page.ai_left + (PADDLE_WIDTH - BALL_SIZE) / 2.0;
if page.auto_serve_timer <= 0.0 {
serve_page(page, cx, true);
}
} else {
if page.player_movement != 0.0 {
page.player_has_moved = true;
}
page.player_left = move_paddle(page.player_left, page.player_movement, delta);
page.ball.left = page.player_left + (PADDLE_WIDTH - BALL_SIZE) / 2.0;
if page.player_has_moved {
wander(page, delta, 0.3, 0.5, 0.7);
page.ai_left = move_paddle(page.ai_left, page.ai_wander_dir * 0.3, delta);
}
}
return;
}
Phase::Scored => {
page.phase_timer -= delta;
if page.phase_timer <= 0.0 {
if page.player_score >= WIN_SCORE || page.ai_score >= WIN_SCORE {
page.phase = Phase::GameOver;
let text = if page.player_score >= WIN_SCORE { "YOU WIN!\nTAP TO RESTART" } else { "AI WINS!\nTAP TO RESTART" };
set_message(cx, page, text);
} else {
let player_serves = page.last_scorer_player;
page.ai_serves = !player_serves;
page.auto_serve_timer = 1.5;
page.player_has_moved = false;
page.ai_wander_dir = 0.0;
reset_paddles(page);
reset_ball(page, player_serves);
page.phase = Phase::WaitingToStart;
set_message(cx, page, if page.ai_serves { "" } else { "TAP TO SERVE" });
}
}
return;
}
Phase::GameOver => return,
Phase::Playing => {}
}
page.ball.update_position(delta);
if page.ball.left < 0.0 {
page.ball.left = 0.0;
let angle = PI - page.ball.angle;
page.ball.set_angle(angle, &mut page.random);
} else if page.ball.left + BALL_SIZE > WIDTH {
page.ball.left = WIDTH - BALL_SIZE;
let angle = PI - page.ball.angle;
page.ball.set_angle(angle, &mut page.random);
}
let ball_hit = page.ball.hit_box();
let max_deviation = PI * 0.27;
let max_speed = BALL_SPEED * 2.0;
let player_hit = HitBox::of(page.player_left, HEIGHT - PADDLE_MARGIN - PADDLE_HEIGHT, PADDLE_WIDTH, PADDLE_HEIGHT);
if ball_hit.intersects(&player_hit) && page.ball.angle > 0.0 {
let hit = (ball_hit.mid_x() - player_hit.left) / player_hit.width();
page.ball.set_angle(-PI / 2.0 + (hit - 0.5) * max_deviation * 2.0, &mut page.random);
if page.ball.angle.sin() > 0.0 {
let angle = -page.ball.angle;
page.ball.set_angle(angle, &mut page.random);
}
page.ball.top = player_hit.top - BALL_SIZE;
page.ball.speed = (page.ball.speed + 20.0).min(max_speed);
}
let ai_hit = HitBox::of(page.ai_left, PADDLE_MARGIN, PADDLE_WIDTH, PADDLE_HEIGHT);
if ball_hit.intersects(&ai_hit) && page.ball.angle < 0.0 {
let hit = (ball_hit.mid_x() - ai_hit.left) / ai_hit.width();
page.ball.set_angle(PI / 2.0 + (hit - 0.5) * max_deviation * 2.0, &mut page.random);
if page.ball.angle.sin() < 0.0 {
let angle = -page.ball.angle;
page.ball.set_angle(angle, &mut page.random);
}
page.ball.top = ai_hit.bottom;
page.ball.speed = (page.ball.speed + 20.0).min(max_speed);
}
if page.ball.top + BALL_SIZE < 0.0 {
page.player_score += 1;
update_score(cx, page);
score(page, cx, true);
return;
}
if page.ball.top > HEIGHT {
page.ai_score += 1;
update_score(cx, page);
score(page, cx, false);
return;
}
page.player_left = move_paddle(page.player_left, page.player_movement, delta);
page.ai_left = move_paddle(page.ai_left, page.ai_movement, delta);
update_ai(page, delta);
}
/// The AI paddle drifting while waiting for a serve (both wander blocks of PongGame.Loop.cs).
fn wander(page: &mut App, delta: f32, stay_probability: f32, min_secs: f32, range_secs: f32) {
page.ai_wander_timer -= delta;
if page.ai_wander_timer <= 0.0 {
page.ai_wander_dir = if page.random.next() < stay_probability {
0.0
} else if page.random.next() < 0.5 {
-1.0
} else {
1.0
};
page.ai_wander_timer = min_secs + page.random.next() * range_secs;
}
if page.ai_left <= 0.0 && page.ai_wander_dir < 0.0 {
page.ai_wander_dir = 1.0;
}
if page.ai_left + PADDLE_WIDTH >= WIDTH && page.ai_wander_dir > 0.0 {
page.ai_wander_dir = -1.0;
}
}
fn score(page: &mut App, cx: &mut Cx, by_player: bool) {
page.last_scorer_player = by_player;
page.ball.moving = false;
page.phase = Phase::Scored;
page.phase_timer = 1.5;
set_message(cx, page, if by_player { "POINT!" } else { "AI SCORES!" });
}
fn move_paddle(left: f32, direction: f32, delta: f32) -> f32 {
if direction == 0.0 {
return left;
}
(left + direction * PADDLE_SPEED * delta).clamp(0.0, WIDTH - PADDLE_WIDTH)
}
// ---------------------------------------------------------------- the AI (PongAI.cs)
fn reaction_time(random: &mut Random) -> f32 {
random.next() * (REACTION_MAX - REACTION_MIN) + REACTION_MIN
}
fn reset_ai(page: &mut App) {
let ai = &mut page.ai;
ai.reaction_timer = reaction_time(&mut page.random);
ai.mistake_timer = 0.0;
ai.decision_change_timer = ai.decision_change_interval;
ai.movement_smoothing_timer = 0.0;
ai.making_mistake = false;
ai.mistake_direction = 0.0;
ai.is_moving = false;
ai.last_movement = 0.0;
page.ai_movement = 0.0;
}
fn set_ai_movement(page: &mut App, direction: f32) {
if direction == page.ai.last_movement {
return;
}
page.ai.last_movement = direction;
page.ai.is_moving = direction != 0.0;
page.ai_movement = direction;
}
fn move_toward_target(page: &mut App) {
if page.ai.movement_smoothing_timer > 0.0 {
return;
}
page.ai.movement_smoothing_timer = SMOOTHING / 2.0;
let distance = page.ai.target_x - page.ai_left;
if distance.abs() < PADDLE_WIDTH * 0.15 {
set_ai_movement(page, 0.0);
} else {
set_ai_movement(page, distance.signum());
}
}
fn update_ai(page: &mut App, delta: f32) {
if !page.ball.moving {
set_ai_movement(page, 0.0);
return;
}
page.ai.reaction_timer -= delta;
page.ai.movement_smoothing_timer -= delta;
if page.ai.making_mistake {
page.ai.mistake_timer -= delta;
if page.ai.mistake_timer <= 0.0 {
page.ai.making_mistake = false;
page.ai.reaction_timer = reaction_time(&mut page.random);
set_ai_movement(page, 0.0);
} else {
let direction = page.ai.mistake_direction;
set_ai_movement(page, direction);
}
return;
}
page.ai.decision_change_timer -= delta;
if page.ai.decision_change_timer <= 0.0 {
if page.random.next() < MISTAKE_PROBABILITY {
page.ai.making_mistake = true;
page.ai.mistake_timer = page.random.next() * (MISTAKE_MAX - MISTAKE_MIN) + MISTAKE_MIN;
page.ai.mistake_direction = if page.random.next() < 0.7 {
if page.random.next() < 0.5 { -1.0 } else { 1.0 }
} else {
0.0
};
}
page.ai.decision_change_interval = 0.5 + page.random.next();
page.ai.decision_change_timer = page.ai.decision_change_interval;
}
if page.ai.making_mistake {
let direction = page.ai.mistake_direction;
set_ai_movement(page, direction);
return;
}
let ball = &page.ball;
let coming_up = ball.angle.sin() < 0.0;
if coming_up && page.ai.reaction_timer <= 0.0 {
let (velocity_x, velocity_y) = (ball.angle.cos() * ball.speed, ball.angle.sin() * ball.speed);
// The hit box is in field space, the same origin as Left / Top.
let paddle_center_y = PADDLE_MARGIN + PADDLE_HEIGHT / 2.0;
let time_to_intersect = (paddle_center_y - ball.hit_box().mid_y()) / velocity_y;
if time_to_intersect > 0.0 {
let mut predicted = ball.hit_box().mid_x() + velocity_x * time_to_intersect;
// Bounces off the side walls, bounded.
for _ in 0..16 {
if predicted < 0.0 {
predicted = -predicted;
} else if predicted > WIDTH {
predicted = 2.0 * WIDTH - predicted;
} else {
break;
}
}
let max_error = (1.0 - ACCURACY) * PADDLE_WIDTH;
let error = (page.random.next() * 2.0 - 1.0) * max_error;
page.ai.target_x = predicted + error - PADDLE_WIDTH / 2.0;
page.ai.reaction_timer = reaction_time(&mut page.random);
move_toward_target(page);
}
} else if !coming_up && page.ai.movement_smoothing_timer <= 0.0 {
let center_x = (WIDTH - PADDLE_WIDTH) / 2.0;
if (page.ai_left - center_x).abs() > PADDLE_WIDTH {
page.ai.target_x = center_x;
move_toward_target(page);
} else if page.ai.is_moving && page.random.next() < 0.3 {
set_ai_movement(page, 0.0);
}
page.ai.movement_smoothing_timer = SMOOTHING;
}
}