/// Browser chess demo driven from MoonBit.
///
/// The DOM lives in JS (see `ffi_js.mbt`); this module owns the game state,
/// applies moves, renders through the FFI, and lets the engine answer.
/// Interaction: click a piece of the side to move, then click a target square.
///|
/// Mutable application state.
struct App {
mut game : @lib.Chess
mut selected : Int32
mut last_from : Int32
mut last_to : Int32
mut animate : Bool
mut busy : Bool
mut pending_from : Int32
mut pending_to : Int32
}
///|
let app : App = {
game: @lib.Chess::new(),
selected: -1,
last_from: -1,
last_to: -1,
animate: false,
busy: false,
pending_from: -1,
pending_to: -1,
}
///|
/// Human-readable status line.
fn status_text(g : @lib.Chess) -> String {
if g.is_checkmate() {
"checkmate"
} else if g.is_stalemate() {
"stalemate"
} else if g.is_draw() {
"draw"
} else {
let side = if g.side_is_white() { "White" } else { "Black" }
if g.is_check() {
"\{side} to move — check"
} else {
"\{side} to move"
}
}
}
///|
/// Comma-separated destination squares for the selected piece.
fn targets_for(g : @lib.Chess, from : Int32) -> String {
if from < 0 {
return ""
}
let mut out = ""
for m in g.moves() {
if m.from == from {
if out != "" {
out = out + ","
}
out = out + m.to.to_string()
}
}
out
}
///|
/// Render the board, hints and status through the FFI.
fn render() -> Unit {
let g = app.game
let mut cells = ""
let mut rank = 7
while rank >= 0 {
let mut file = 0
while file < 8 {
match g.piece_on(@lib.make_square(file, rank)) {
Some(p) => cells = cells + p.fen_char().to_string()
None => cells = cells + "."
}
file = file + 1
}
rank = rank - 1
}
js_render(
cells,
status_text(g),
app.selected,
targets_for(g, app.selected),
app.last_from,
app.last_to,
app.animate,
)
let (by_white, by_black) = g.captured_glyphs()
js_set_captured(by_white, by_black)
}
///|
/// Render after a move, animating the piece from `from` to `to` once.
fn render_move(m : @lib.Move) -> Unit {
app.last_from = m.from
app.last_to = m.to
app.animate = true
render()
app.animate = false
}
///|
/// Let the engine play one move for the side to move.
fn engine_reply() -> Unit {
let g = app.game
if g.is_game_over() || g.side_is_white() {
app.busy = false
app.animate = false
render()
return
}
let searcher = @lib.Searcher::new()
let depth = js_engine_depth()
match searcher.search(g, depth).best {
Some(m) => {
g.make_move(m)
render_move(m)
}
None => {
app.animate = false
render()
}
}
app.busy = false
}
///|
/// Handle a click on board cell `(row, col)` where row 0 is rank 8.
fn on_cell(row : Int32, col : Int32) -> Unit {
let g = app.game
if app.busy || g.is_game_over() {
return
}
let sq = @lib.make_square(col, 7 - row)
match g.piece_on(sq) {
Some(p) =>
if p.is_white() == g.side_is_white() {
app.selected = sq
app.animate = false
render()
return
}
None => ()
}
if app.selected < 0 {
return
}
let from = app.selected
// A pawn reaching the last rank: ask the player which piece to promote to.
if g.promotion_piece(from, sq) is Some(_) {
app.pending_from = from
app.pending_to = sq
app.selected = -1
app.busy = true
render()
js_show_promotion(g.side_is_white(), on_promotion_pick)
return
}
match g.play(@lib.square_name(from), @lib.square_name(sq), None) {
Some(m) => {
app.selected = -1
app.busy = true
render_move(m)
// Let the player's move animate, then let the engine think and reply.
js_defer(engine_reply, 220)
}
None => {
app.selected = -1
app.animate = false
render()
}
}
}
///|
/// Apply the promotion the player picked (0 = queen, 1 = rook, 2 = bishop,
/// 3 = knight).
fn on_promotion_pick(index : Int32) -> Unit {
let g = app.game
let from = app.pending_from
let to = app.pending_to
js_hide_promotion()
if from < 0 || to < 0 {
app.busy = false
render()
return
}
let promo = @lib.promotion_from_index(index)
match g.play(@lib.square_name(from), @lib.square_name(to), Some(promo)) {
Some(m) => {
app.pending_from = -1
app.pending_to = -1
render_move(m)
js_defer(engine_reply, 220)
}
None => {
app.pending_from = -1
app.pending_to = -1
app.busy = false
app.animate = false
render()
}
}
}
///|
/// Start a fresh game.
fn on_new_game() -> Unit {
app.game.reset()
app.selected = -1
app.last_from = -1
app.last_to = -1
app.animate = false
app.busy = false
app.pending_from = -1
app.pending_to = -1
js_hide_promotion()
render()
}
///|
/// Undo the last full move (the player's move and the engine's reply).
fn on_undo() -> Unit {
let g = app.game
ignore(g.undo())
ignore(g.undo())
app.selected = -1
app.last_from = -1
app.last_to = -1
app.animate = false
app.busy = false
app.pending_from = -1
app.pending_to = -1
js_hide_promotion()
render()
}
///|
fn main {
js_dom_init()
js_set_on_cell(on_cell)
js_set_on_new(on_new_game)
js_set_on_undo(on_undo)
js_bind_buttons()
// Allow sharing a position via ?fen=...
let fen = js_initial_fen()
if fen != "" {
match @lib.Chess::from_fen(fen) {
Some(loaded) => app.game = loaded
None => ()
}
}
render()
}