// Copyright 2026 Ling71671
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Frame payload for the standalone minesweeper page (`minesweeper.html`).
pub struct MinesFrame {
draw_list : @draw.DrawList
revealed : Int
flagged : Int
hits : Int
pending : Bool
flag_mode : Bool
/// Hand tool: the left button drags the camera instead of digging. Read back
/// by the toolbar so the button and the board cannot disagree about the mode.
pan_mode : Bool
cursor : String
/// Tile under the pointer, so the host can print the cursor coordinate.
tile_x : Int
tile_y : Int
/// Current tile size in pixels - drives the host's zoom readout.
cell : Double
/// The window of the domain currently on screen. The board is 655 million
/// tiles and the viewport holds a few thousand of them at any zoom the
/// board stays readable at, so the overview map and the coverage figure are
/// the only way a player can see how little of the field is in front of
/// them - without them, "100 million" and "1 million" look identical.
domain_size : Int
view_x : Int
view_y : Int
view_cols : Int
view_rows : Int
navigate_url : String
}
///|
struct MinesState {
mut world : MineWorld
mut cam_x : Double
mut cam_y : Double
mut cell : Double
mut flag_mode : Bool
mut pan_mode : Bool
mut prev_down : Bool
mut prev_secondary : Bool
mut gesture_flag : Bool
mut drag_move : Bool
mut inited : Bool
}
///|
/// Zoom bounds. MIN_CELL is also the legibility floor: a digit stops fitting
/// its tile below it, so gating the zoom at the same value means the board
/// can never reach an unreadable state (a blank white mass with no numbers).
const MIN_CELL : Double = 12.0
///|
const MAX_CELL : Double = 56.0
///|
/// Zoom the field starts at, and the unit the toolbar's zoom readout is a
/// percentage of.
const DEFAULT_CELL : Double = 22.0
///|
const ZOOM_STEP : Double = 1.2
///|
/// Zoom by `factor`, keeping the world point under (anchor_x, anchor_y) fixed.
/// The wheel gesture anchors at the pointer; the toolbar buttons anchor at the
/// centre of the viewport (there is no pointer to anchor on).
fn mines_zoom_about(
st : MinesState,
anchor_x : Double,
anchor_y : Double,
factor : Double,
) -> Unit {
let old_cell = st.cell
let scaled = old_cell * factor
let new_cell = if scaled > MAX_CELL {
MAX_CELL
} else if scaled < MIN_CELL {
MIN_CELL
} else {
scaled
}
if new_cell == old_cell {
return
}
let wx = (anchor_x + st.cam_x) / old_cell
let wy = (anchor_y + st.cam_y) / old_cell
st.cell = new_cell
st.cam_x = wx * new_cell - anchor_x
st.cam_y = wy * new_cell - anchor_y
}
///|
/// Park the camera on the middle of the domain. The minefield only exists for
/// x, y >= 0, so anchoring on the origin put the domain's corner at the centre
/// of the screen and half the initial view was a mine-free void: no numbers,
/// no cascade, one tile per click - which read as "this board is empty, there
/// can't really be 100M mines".
///
/// Tiles are drawn at `t * cell - cam`, so placing tile DOMAIN_SIZE/2 at the
/// centre of the viewport means `cam = (DOMAIN_SIZE/2) * cell - vp/2`. Get the
/// sign wrong and the camera lands in the void again, one whole domain away.
fn mines_centre_camera(st : MinesState, vp_w : Double, vp_h : Double) -> Unit {
let mid = DOMAIN_SIZE.to_double() * 0.5 * st.cell
st.cam_x = mid - vp_w * 0.5
st.cam_y = mid - vp_h * 0.5
}
///|
/// Number font, scaled with the tile so a digit always fits its cell.
fn mines_number_font(cell : Double) -> Double {
let f = cell * 0.58
if f < 9.0 {
9.0
} else if f > 15.0 {
15.0
} else {
f
}
}
///|
let mines_st : MinesState = {
world: MineWorld::new(20260913UL),
cam_x: -14.0,
cam_y: -12.0,
cell: 22.0,
flag_mode: false,
pan_mode: false,
prev_down: false,
prev_secondary: false,
gesture_flag: false,
drag_move: false,
inited: false,
}
///|
let mines_ctx : @core.UIContext = @core.UIContext::new()
///|
fn mines_number_color(n : Int) -> @color.Color {
if n == 1 {
@color.Color::accent_primary()
} else if n == 2 {
@color.Color::success()
} else if n == 3 {
@color.Color::warning()
} else if n == 4 {
@color.Color::danger()
} else if n <= 7 {
@color.Color::text_strong()
} else {
@color.Color::text_secondary()
}
}
///|
fn mines_next_seed(seed : UInt64) -> UInt64 {
seed * 6364136223846793005UL + 1442695040888963407UL
}
///|
/// A flag tile: a mast with a pennant hanging off its top, pointing right.
/// The pennant is a filled right triangle drawn as horizontal bars (the
/// painter has no polygon primitive). The bar count tracks the cell size
/// instead of being fixed: with a fixed handful of bars the hypotenuse came
/// out as four visible treads, which at the top of the zoom range read as a
/// staircase rather than a flag. Every other coordinate is a fraction of the
/// cell, so the glyph holds its shape at any zoom.
fn mines_draw_flag(
painter : @core.Painter,
sx : Double,
sy : Double,
cell : Double,
) -> Unit {
let raw_mast = cell * 0.075
let mast_w = if raw_mast < 1.2 {
1.2
} else if raw_mast > 3.0 {
3.0
} else {
raw_mast
}
let left = sx + cell * 0.28
// mast: a hair-thin rounded bar, dark so it reads against the ground
painter.add_rect(
@math.Rect::new(left, sy + cell * 0.15, mast_w, cell * 0.70),
@color.Color::text_secondary(),
mast_w * 0.5,
)
let steps = (cell * 0.55).to_int()
let steps = if steps < 6 { 6 } else if steps > 26 { 26 } else { steps }
let height = cell * 0.42
// Each bar overshoots its slice slightly so antialiasing cannot open a
// hairline seam between two neighbouring bars.
let bar = height / steps.to_double() + 0.6
for k = 0; k < steps; k = k + 1 {
let t = k.to_double() / steps.to_double()
let w = cell * (0.46 - 0.40 * t)
if w > 0.4 {
painter.add_rect(
@math.Rect::new(left + mast_w, sy + cell * 0.15 + height * t, w, bar),
@color.Color::accent_primary(),
0.0,
)
}
}
}
///|
/// Per-frame entry point of the standalone minesweeper page. The host only
/// feeds pointer / wheel / button state - everything else (procedural board,
/// cascade, rendering) lives in this deterministic core.
pub fn mines_step(
mouse_x : Double,
mouse_y : Double,
mouse_down : Bool,
mouse_secondary_down : Bool,
pan_dx : Double,
pan_dy : Double,
zoom_delta : Double,
action_switch : Int,
vp_w : Double,
vp_h : Double,
) -> MinesFrame {
let st = mines_st
let raw = @core.RawInput::from_mouse(
@math.Vec2::new(mouse_x, mouse_y),
mouse_down,
mouse_secondary_down~,
)
mines_ctx.begin_frame(raw)
if !st.inited {
st.inited = true
// Camera is in PIXELS: world tile t renders at t * cell - cam, so the
// inverse map for hit testing is (screen + cam) / cell. Both sides must
// agree or clicks land off the tile they visually target.
mines_centre_camera(st, vp_w, vp_h)
}
// 1. HTML controls -> world actions
if action_switch == 0 {
st.world = MineWorld::new(mines_next_seed(st.world.seed()))
}
if action_switch == 1 {
st.flag_mode = !st.flag_mode
}
// Action 6: the hand tool. It only decides what the LEFT button does - the
// right button keeps flagging, because flagging is orthogonal to whether the
// camera is dragged or the tile is dug.
if action_switch == 6 {
st.pan_mode = !st.pan_mode
}
// Toolbar zoom: 2 = in, 3 = out, 4 = back to DEFAULT_CELL, 5 = recentre.
// Buttons have no pointer to anchor on, so they zoom about the middle of
// the viewport.
if action_switch == 2 || action_switch == 3 {
let factor = if action_switch == 2 { ZOOM_STEP } else { 1.0 / ZOOM_STEP }
mines_zoom_about(st, vp_w * 0.5, vp_h * 0.5, factor)
}
if action_switch == 4 {
mines_zoom_about(st, vp_w * 0.5, vp_h * 0.5, DEFAULT_CELL / st.cell)
}
if action_switch == 5 {
mines_centre_camera(st, vp_w, vp_h)
}
let scale = mines_ctx.style.scale
let top_bar_h = 38.0 * scale
let bottom_bar_h = 28.0 * scale
let in_board = mouse_x >= 0.0 &&
mouse_x <= vp_w &&
mouse_y >= top_bar_h &&
mouse_y <= vp_h - bottom_bar_h
// 2. camera: pan + zoom anchored at the pointer
st.cam_x = st.cam_x - pan_dx
st.cam_y = st.cam_y - pan_dy
let pressed = mouse_down && !st.prev_down
let released = !mouse_down && st.prev_down
let secondary_pressed = mouse_secondary_down && !st.prev_secondary
let secondary_released = !mouse_secondary_down && st.prev_secondary
if pressed || secondary_pressed {
st.drag_move = false
st.gesture_flag = st.flag_mode || mouse_secondary_down
}
// The right button plants a flag the moment it goes down (no right-drag
// gesture exists, so there is nothing to disambiguate).
if secondary_pressed && !st.drag_move && in_board {
let wx = (mouse_x + st.cam_x) / st.cell
let wy = (mouse_y + st.cam_y) / st.cell
st.world.toggle_flag(wx.floor().to_int(), wy.floor().to_int())
}
if mouse_down && (pan_dx != 0.0 || pan_dy != 0.0) && in_board {
st.drag_move = true
}
if zoom_delta != 0.0 && in_board {
// wheel zoom anchors on the pointer, so the tile under the cursor stays put
let factor = if zoom_delta > 0.0 { ZOOM_STEP } else { 1.0 / ZOOM_STEP }
mines_zoom_about(st, mouse_x, mouse_y, factor)
}
// 3. click resolution on release (a dragged gesture is a pan, not a click)
let world = st.world
let tx = ((mouse_x + st.cam_x) / st.cell).floor().to_int()
let ty = ((mouse_y + st.cam_y) / st.cell).floor().to_int()
// Pointer feedback: digging feels live only if the unopened earth says
// "clickable". The pointer leaves the canvas as a far-out sentinel rather
// than a flag, so it has to be range-checked - otherwise the off-canvas
// sentinel lands on some unopened tile out in the void and the cursor keeps
// claiming "pointer" while the mouse is nowhere near the board.
//
// The board is draggable in every mode, and a board that never says so is a
// board nobody pans. The hand tool makes it explicit (open hand over the
// board, closed hand while the camera is actually moving); in the default
// dig mode the open hand would fight the dig pointer, so the affordance
// there is the hint chip the host shows on the first visit.
let inside = mouse_x >= 0.0 &&
mouse_y >= 0.0 &&
mouse_x <= vp_w &&
mouse_y <= vp_h
let cursor = if !inside {
"default"
} else if !in_board {
let c = mines_ctx.cursor_icon()
if c != "" {
c
} else {
"default"
}
} else if st.drag_move {
"grabbing"
} else if st.pan_mode {
"grab"
} else if !world.is_revealed(tx, ty) {
"pointer"
} else {
"default"
}
// Left gesture: release reveals - or flags while the flag mode is on. With
// the hand tool up the left button belongs to the camera, so a press and
// release that covered no distance does nothing at all: a pan mode that
// still opened a tile on a stray click would be worse than no pan mode.
if released && !st.drag_move && !st.pan_mode && in_board {
let cx = ((mouse_x + st.cam_x) / st.cell).floor().to_int()
let cy = ((mouse_y + st.cam_y) / st.cell).floor().to_int()
if st.gesture_flag {
world.toggle_flag(cx, cy)
} else {
world.reveal(cx, cy)
}
}
if released || secondary_released {
st.drag_move = false
}
st.prev_down = mouse_down
st.prev_secondary = mouse_secondary_down
// 4. advance the animated cascade
let _ = world.pump(8000)
// 5. render the visible window of the board
let painter = mines_ctx.painter()
// The backdrop is the seam between tiles, not the tile itself: every
// unopened tile then paints over it one pixel short, so the backdrop is
// what shows through as a hairline grid. Painting the tiles themselves was
// the point - an untouched field is ~99% unopened at this zoom, and with
// no seams it read as one blank grey slab.
painter.add_rect(
@math.Rect::new(0.0, 0.0, vp_w, vp_h),
@color.Color::mines_grid(),
0.0,
)
let cell = st.cell
let col0 = (st.cam_x / cell).floor().to_int()
let row0 = (st.cam_y / cell).floor().to_int()
// The draw loop keeps two spare rows and columns so a tile straddling the
// edge is never dropped. The reported window is the honest one: those two
// spares are drawn but not visible, and padding the scale readout with them
// would overstate the coverage by ~9% at the default zoom.
let cols = (vp_w / cell).to_int() + 2
let rows = (vp_h / cell).to_int() + 2
let view_cols = (vp_w / cell).ceil().to_int()
let view_rows = (vp_h / cell).ceil().to_int()
// Everything below the legibility floor is unreachable (the zoom clamps at
// MIN_CELL), so numbers, flags and the hover tile are drawn on every frame
// instead of being switched off by a level-of-detail test. A zoomed-out
// board stays readable instead of turning into a blank white mass.
let num_font = mines_number_font(cell)
for r = row0; r < row0 + rows; r = r + 1 {
for c = col0; c < col0 + cols; c = c + 1 {
let sx = c.to_double() * cell - st.cam_x
let sy = r.to_double() * cell - st.cam_y
let tile = @math.Rect::new(sx, sy, cell - 1.0, cell - 1.0)
if world.is_revealed(c, r) {
painter.add_rect(tile, @color.Color::bg_window(), 0.0)
if world.is_mine(c, r) {
painter.add_rect(tile, @color.Color::danger(), 0.0)
painter.add_line(
@math.Vec2::new(sx + cell * 0.3, sy + cell * 0.3),
@math.Vec2::new(sx + cell * 0.7, sy + cell * 0.7),
@color.Color::bg_window(),
1.5,
)
painter.add_line(
@math.Vec2::new(sx + cell * 0.7, sy + cell * 0.3),
@math.Vec2::new(sx + cell * 0.3, sy + cell * 0.7),
@color.Color::bg_window(),
1.5,
)
} else {
let adj = world.adjacent(c, r)
if adj > 0 {
let label = adj.to_string()
let ty = sy + (cell - num_font) * 0.5
let tx = sx +
(cell - mines_ctx.measure_text(label, num_font).x) * 0.5
painter.add_text(
@math.Vec2::new(tx, ty),
label,
num_font,
mines_number_color(adj),
font_family="monospace",
)
}
}
} else {
// Unopened tile. Drawn one pixel short of the cell so the grid
// backdrop shows through as its seam; the pointer tile lifts to a
// lighter earth tone that is still clearly not a dug-out pit.
let is_hover = ((mouse_x + st.cam_x) / cell).floor().to_int() == c &&
((mouse_y + st.cam_y) / cell).floor().to_int() == r
painter.add_rect(
tile,
if is_hover {
@color.Color::mines_ground_hover()
} else {
@color.Color::mines_ground()
},
0.0,
)
if world.is_flagged(c, r) {
mines_draw_flag(painter, sx, sy, cell)
}
}
}
}
let nav_url = render_mines_header_bar(mines_ctx, st, vp_w, vp_h, top_bar_h)
render_mines_footer_bar(
mines_ctx, st, vp_w, vp_h, bottom_bar_h, mouse_x, mouse_y, mouse_down, view_cols,
view_rows,
)
{
draw_list: mines_ctx.end_frame(),
revealed: world.revealed_count(),
flagged: world.flagged_count(),
hits: world.hits(),
pending: world.is_cascade_pending(),
flag_mode: st.flag_mode,
pan_mode: st.pan_mode,
cursor,
tile_x: tx,
tile_y: ty,
cell: st.cell,
domain_size: DOMAIN_SIZE,
view_x: col0,
view_y: row0,
view_cols,
view_rows,
navigate_url: nav_url,
}
}