diff --git a/README.md b/README.md index 7e53bab..5573c52 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Jogo de matemática **para 2 jogadores no mesmo ecrã (até 4 num tablet)** — Pousem o telemóvel na mesa entre os dois: cada jogador fica numa ponta, com o seu avatar e a sua cor. Vão aparecendo contas cada vez mais difíceis, com 4 hipóteses de resposta. **Acertar faz a tua cor expandir pelo tabuleiro** (e roubar casas ao adversário!); **errar faz a tua cor encolher** um bocadinho. Ganha quem conquistar o tabuleiro todo — ou quem tiver mais território quando o tempo acabar. -Num **tablet** podem jogar **3 ou 4**: cada um senta-se num lado do ecrã e tudo o que é seu (contas, respostas, avatar) fica virado para si. +Num **tablet** podem jogar **3 ou 4**: cada um senta-se num lado do ecrã e tudo o que é seu (contas, respostas, avatar) fica virado para si. Com 3, o tabuleiro é um **triângulo** 🔺. Corre na **web** (também instalável como app e funciona offline) e em **Android** (APK nativo via Capacitor). @@ -41,6 +41,7 @@ Com teclado (versão web em computador): Jogador 1 usa `1 2 3 4` ou `A S D F`, J Em ecrãs grandes (o lado mais curto com pelo menos 600 px — tablets, não telemóveis) aparece no menu o botão **👥 2/3/4 jogadores**. Com 3 ou 4: - Cada jogador fica num lado: **1 em baixo, 2 em cima, 3 à esquerda, 4 à direita** — o menu, as contas, o avatar e os cartões de pausa e de resultado ficam virados para cada um. +- **Com 3 jogadores o tabuleiro é um triângulo** feito de triângulos pequenos, com um lado virado para cada jogador. Com o tablet em pé aponta para a direita ▶; deitado, o jogador 2 passa para a direita e o triângulo aponta para cima ▲, para aproveitar o ecrã. Com 4 o tabuleiro é quadriculado. - O tabuleiro tem um avatar em cada lado e podes roubar casas a qualquer adversário. - Quem fica sem território **sai do jogo** (😵 FORA!) e os outros continuam; ganha o último que resta, ou quem tiver mais território quando o tempo acabar. - O 😈 diabinho vai sempre para o adversário com mais território. @@ -66,7 +67,7 @@ Truque para testar: abre `?bots=1,2` no URL e o computador joga sozinho dos dois ``` src/ questions.ts gerador de contas (operações por nível, números a crescer por etapa) e respostas erradas plausíveis - board.ts lógica do tabuleiro: expandir, encolher, roubar, inundação final + board.ts lógica do tabuleiro (quadriculado, ou triangular a 3): expandir, encolher, roubar, inundação final rules.ts equilíbrio do jogo: recompensas, penalizações, subida de nível renderer.ts desenho do tabuleiro em canvas, com animação de "tinta a espalhar" avatar.ts avatares SVG com expressões (feliz, triste, espantado, coroa…) diff --git a/src/board.ts b/src/board.ts index 85beb9d..218f631 100644 --- a/src/board.ts +++ b/src/board.ts @@ -1,8 +1,15 @@ /** - * Territory board. Logical coordinates: `x` goes across (0..w-1) and `y` goes - * along the main axis between players 1 and 2. Player 2 lives at y = 0, - * player 1 at y = h - 1. With 3-4 players, player 3 lives at x = 0 and - * player 4 at x = w - 1. The renderer decides how this maps to the screen. + * Territory board. + * + * Grid boards (2 and 4 players): square cells. Logical coordinates: `x` goes + * across (0..w-1) and `y` goes along the main axis between players 1 and 2. + * Player 2 lives at y = 0, player 1 at y = h - 1. With 4 players, player 3 + * lives at x = 0 and player 4 at x = w - 1. + * + * Triangle boards (3 players): a big triangle cut into n² small ones, each + * player in the middle of one side (see `triCell`). + * + * The renderer decides how this maps to the screen. */ import type { Rng } from './questions'; @@ -15,6 +22,148 @@ export const ALL_PLAYERS: readonly PlayerId[] = [1, 2, 3, 4]; /** The first `n` players (2 to 4). */ export const playerIds = (n: number): PlayerId[] => ALL_PLAYERS.slice(0, Math.max(2, Math.min(4, n))); +export type BoardShape = 'grid' | 'triangle'; + +/** Height of an equilateral triangle of side 1. */ +export const H3 = Math.sqrt(3) / 2; + +type Pt = [number, number]; + +/** + * Small triangle `i` of a triangle board of side `n`, in canonical + * coordinates: the big triangle points up (apex at (n/2, 0), y grows + * downwards) and every small triangle has side 1. Row r (from the apex) + * holds 2r + 1 triangles, alternately pointing up and down; cell (r, k) is + * index r² + k. + */ +export function triCell(n: number, i: number): { pts: [Pt, Pt, Pt]; c: Pt; up: boolean } { + const r = Math.floor(Math.sqrt(i + 0.5)); + const k = i - r * r; + const left = n / 2 - (r + 1) / 2; // left end of the row, at its bottom + const top = r * H3; + const bottom = (r + 1) * H3; + const j = k >> 1; + if (k % 2 === 0) { + return { pts: [[left + j, bottom], [left + j + 0.5, top], [left + j + 1, bottom]], c: [left + j + 0.5, bottom - H3 / 3], up: true }; + } + return { pts: [[left + j + 0.5, top], [left + j + 1.5, top], [left + j + 1, bottom]], c: [left + j + 1, top + H3 / 3], up: false }; +} + +/** + * The side of the big triangle each player faces (canonical coordinates). + * The game draws it pointing right ▶, so the base ends up on the left + * (player 3), the left side at the top (player 2) and the right side at the + * bottom (player 1): each side faces the player sitting there. + */ +export function triEdge(n: number, p: PlayerId): { mid: Pt; inward: Pt } { + switch (p) { + case 3: + return { mid: [n / 2, n * H3], inward: [0, -1] }; + case 2: + return { mid: [n / 4, (n * H3) / 2], inward: [H3, 0.5] }; + default: + return { mid: [(3 * n) / 4, (n * H3) / 2], inward: [-H3, 0.5] }; + } +} + +interface Topology { + nbrs: number[][]; + home: Partial>; + start: Partial>; + /** How far "forward" a cell is for a player, 0 at their home edge, 1 at the far end. */ + forward(p: PlayerId, i: number): number; +} + +function gridTopology(w: number, h: number, players: readonly PlayerId[]): Topology { + const idx = (x: number, y: number) => y * w + x; + const mx = Math.floor(w / 2); + const my = Math.floor(h / 2); + // Home cell and the cell just in front of it, for each side. + const spots: Record = { + 1: { home: [mx, h - 1], front: [mx, h - 2], along: [1, 0] }, + 2: { home: [mx, 0], front: [mx, 1], along: [1, 0] }, + 3: { home: [0, my], front: [1, my], along: [0, 1] }, + 4: { home: [w - 1, my], front: [w - 2, my], along: [0, 1] }, + }; + const nbrs: number[][] = []; + for (let i = 0; i < w * h; i++) { + const x = i % w; + const y = Math.floor(i / w); + const n: number[] = []; + if (y > 0) n.push(i - w); + if (y < h - 1) n.push(i + w); + if (x > 0) n.push(i - 1); + if (x < w - 1) n.push(i + 1); + nbrs.push(n); + } + const home: Topology['home'] = {}; + const start: Topology['start'] = {}; + for (const p of players) { + // Each player starts with a small blob around their avatar. + const { home: [hx, hy], front, along: [ax, ay] } = spots[p]; + home[p] = idx(hx, hy); + start[p] = [[hx, hy], [hx - ax, hy - ay], [hx + ax, hy + ay], front] + .filter(([x, y]) => x >= 0 && x < w && y >= 0 && y < h) + .map(([x, y]) => idx(x, y)); + } + const forward = (p: PlayerId, i: number): number => { + const x = i % w; + const y = Math.floor(i / w); + switch (p) { + case 1: + return (h - 1 - y) / (h - 1); + case 2: + return y / (h - 1); + case 3: + return x / (w - 1); + case 4: + return (w - 1 - x) / (w - 1); + } + }; + return { nbrs, home, start, forward }; +} + +function triangleTopology(n: number, players: readonly PlayerId[]): Topology { + const size = n * n; + const nbrs: number[][] = []; + for (let r = 0; r < n; r++) { + for (let k = 0; k <= 2 * r; k++) { + const i = r * r + k; + const nb: number[] = []; + if (k % 2 === 0) { + // Pointing up: neighbours left and right in the row, and the one below. + if (k > 0) nb.push(i - 1); + if (k < 2 * r) nb.push(i + 1); + if (r < n - 1) nb.push((r + 1) * (r + 1) + k + 1); + } else { + // Pointing down: left, right and the one above. + nb.push(i - 1, i + 1, (r - 1) * (r - 1) + k - 1); + } + nbrs.push(nb); + } + } + const centres = Array.from({ length: size }, (_, i) => triCell(n, i).c); + const dist = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]); + const home: Topology['home'] = {}; + const start: Topology['start'] = {}; + const fwd: Partial> = {}; + for (const p of players) { + const { mid, inward } = triEdge(n, p); + let best = 0; + for (let i = 1; i < size; i++) if (dist(centres[i], mid) < dist(centres[best], mid)) best = i; + home[p] = best; + // A short strip of 5 triangles along their side, centred on the avatar. + start[p] = centres.flatMap((c, i) => (dist(c, mid) < 1.1 ? [i] : [])); + const f = new Float64Array(size); + for (let i = 0; i < size; i++) { + const c = centres[i]; + f[i] = ((c[0] - mid[0]) * inward[0] + (c[1] - mid[1]) * inward[1]) / (n * H3); + } + fwd[p] = f; + } + return { nbrs, home, start, forward: (p, i) => fwd[p]![i] }; +} + export interface CellChange { idx: number; from: Owner; @@ -26,6 +175,8 @@ export interface CellChange { } export class Board { + readonly shape: BoardShape; + /** Grid: columns and rows. Triangle: both are the side n (in small triangles). */ readonly w: number; readonly h: number; readonly cells: Uint8Array; @@ -34,43 +185,30 @@ export class Board { /** Home cell (where the avatar sits) for each player, indexed by id (-1 when not playing). */ readonly home: [number, number, number, number, number]; private readonly nbrs: number[][]; + private readonly forward: (p: PlayerId, i: number) => number; - constructor(w: number, h: number, players: readonly PlayerId[] = [1, 2]) { + constructor(w: number, h: number, players: readonly PlayerId[] = [1, 2], shape: BoardShape = 'grid') { + this.shape = shape; this.w = w; this.h = h; this.players = players; this.cells = new Uint8Array(w * h); - const mx = Math.floor(w / 2); - const my = Math.floor(h / 2); - // Home cell and the cell just in front of it, for each side. - const spots: Record = { - 1: { home: [mx, h - 1], front: [mx, h - 2], along: [1, 0] }, - 2: { home: [mx, 0], front: [mx, 1], along: [1, 0] }, - 3: { home: [0, my], front: [1, my], along: [0, 1] }, - 4: { home: [w - 1, my], front: [w - 2, my], along: [0, 1] }, - }; + const t = shape === 'triangle' ? triangleTopology(w, players) : gridTopology(w, h, players); + this.nbrs = t.nbrs; + this.forward = t.forward; this.home = [-1, -1, -1, -1, -1]; - for (const p of players) this.home[p] = this.idx(...spots[p].home); - this.nbrs = []; - for (let i = 0; i < w * h; i++) { - const x = i % w; - const y = Math.floor(i / w); - const n: number[] = []; - if (y > 0) n.push(i - w); - if (y < h - 1) n.push(i + w); - if (x > 0) n.push(i - 1); - if (x < w - 1) n.push(i + 1); - this.nbrs.push(n); - } - // Each player starts with a small blob around their avatar. for (const p of players) { - const { home: [hx, hy], front, along: [ax, ay] } = spots[p]; - for (const [x, y] of [[hx, hy], [hx - ax, hy - ay], [hx + ax, hy + ay], front]) { - if (x >= 0 && x < w && y >= 0 && y < h) this.cells[this.idx(x, y)] = p; - } + this.home[p] = t.home[p]!; + for (const i of t.start[p]!) this.cells[i] = p; } } + /** Three players around a triangle of side `n` (odd, so every avatar sits mid-side). */ + static triangle(n: number, players: readonly PlayerId[] = [1, 2, 3]): Board { + if (players.some((p) => p > 3)) throw new Error('A triangle board has room for 3 players'); + return new Board(n, n, players, 'triangle'); + } + idx(x: number, y: number): number { return y * this.w + x; } @@ -98,22 +236,6 @@ export class Board { return this.players.filter((p) => this.count(p) > 0); } - /** How far "forward" a cell is for a player, 0 at their home edge, 1 at the opposite edge. */ - private forward(p: PlayerId, i: number): number { - const x = i % this.w; - const y = Math.floor(i / this.w); - switch (p) { - case 1: - return (this.h - 1 - y) / (this.h - 1); - case 2: - return y / (this.h - 1); - case 3: - return x / (this.w - 1); - case 4: - return (this.w - 1 - x) / (this.w - 1); - } - } - /** * Player `p` paints up to `n` cells adjacent to their territory. Neutral * cells are preferred, but cells of any opponent can be stolen too. An diff --git a/src/renderer.ts b/src/renderer.ts index 5be1742..709717a 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -3,7 +3,7 @@ * every change of owner plays a "paint spreading" animation. */ -import type { Board, CellChange, Owner, PlayerId } from './board'; +import { H3, triCell, triEdge, type Board, type CellChange, type Owner, type PlayerId } from './board'; import type { PlayerColor } from './colors'; export type Orientation = 'portrait' | 'landscape'; @@ -20,6 +20,52 @@ export interface PaintEvent { /** Space reserved at each end of the board (every side with 3-4 players) for the avatars, in cells. */ export const AVATAR_MARGIN = 0.95; +/** + * Triangle boards: the "cell" size (used for avatars, messages…) as a share of + * a small triangle's side, so things look about as big as on a square board. + */ +const TRI_CELL = 0.75; + +/** Room around a triangle board for the avatars, in small-triangle sides. */ +const TRI_MARGIN = AVATAR_MARGIN * TRI_CELL; + +type Vec = [number, number]; + +/** + * Turn (clockwise) from the canonical triangle (pointing up, see triCell) to + * the screen: pointing right ▶ on an upright tablet (player 3's side on the + * left), pointing up ▲ on one lying sideways (player 1's side at the bottom). + */ +const triTurn = (o: Orientation): number => ((o === 'landscape' ? 120 : 90) * Math.PI) / 180; + +/** Turns a vector clockwise on screen (y grows downwards). */ +const turn = ([x, y]: Vec, a: number): Vec => [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)]; + +/** Triangle board turned for the screen, in small-triangle sides, centred on its incentre. */ +function triPlace(n: number, o: Orientation): { at: (p: Vec) => Vec; min: Vec; max: Vec } { + const a = triTurn(o); + const g: Vec = [n / 2, (2 * n * H3) / 3]; + const at = (p: Vec) => turn([p[0] - g[0], p[1] - g[1]], a); + // Bounding box of the corners (plus the plate) and of each avatar sitting mid-side. + const min: Vec = [Infinity, Infinity]; + const max: Vec = [-Infinity, -Infinity]; + const grow = ([x, y]: Vec, r: number) => { + min[0] = Math.min(min[0], x - r); + min[1] = Math.min(min[1], y - r); + max[0] = Math.max(max[0], x + r); + max[1] = Math.max(max[1], y + r); + }; + for (const c of [[n / 2, 0], [n, n * H3], [0, n * H3]] as Vec[]) grow(at(c), 0.25); + for (const p of [1, 2, 3] as PlayerId[]) grow(at(triEdge(n, p).mid), TRI_MARGIN); + return { at, min, max }; +} + +/** Pixels per small-triangle side for a triangle board of side `n` in a `w`×`h` area. */ +function triScale(n: number, w: number, h: number, o: Orientation): number { + const { min, max } = triPlace(n, o); + return Math.min(w / (max[0] - min[0]), h / (max[1] - min[1])); +} + const NEUTRAL = 'rgba(255,255,255,0.075)'; const NEUTRAL_EDGE = 'rgba(255,255,255,0.05)'; const PLATE = '#1b1450'; @@ -56,6 +102,13 @@ export class BoardView { private pageY = 0; private pos = new Int32Array(0); // idx -> col,row private grid = new Int32Array(0); // col,row -> idx + // triangle boards (drawn pointing right ▶) + private side = 0; // small triangle side, px + private tv = new Float32Array(0); // idx -> 3 corners (x, y) + private tc = new Float32Array(0); // idx -> centre + private tnb = new Int32Array(0); // idx -> neighbour across each of its 3 sides, or -1 + private outline: [number, number][] = []; + private triMap: (p: [number, number]) => [number, number] = (p) => p; // animation state per cell private aFrom = new Uint8Array(0); @@ -93,6 +146,16 @@ export class BoardView { this.animT = new Float32Array(n); } + get shape(): Board['shape'] { + return this.board.shape; + } + + /** Triangle boards: the biggest side n (odd) whose small triangles are still about `target` px wide. */ + static triangleSizeFor(w: number, h: number, o: Orientation, target = 44): number { + for (let n = 17; n > 7; n -= 2) if (triScale(n, w, h, o) >= target) return n; + return 7; + } + /** * Computes cell size for a given area; static so the game can pick a board size. * `allSides`: avatars sit on every side (3-4 players), so leave room all around. @@ -114,6 +177,11 @@ export class BoardView { this.canvas.height = Math.max(1, Math.round(cssH * this.dpr)); this.canvas.style.width = `${cssW}px`; this.canvas.style.height = `${cssH}px`; + if (this.board.shape === 'triangle') { + this.layoutTriangle(cssW, cssH); + this.updatePageOffset(); + return; + } const { w, h } = this.board; const s = Math.floor(BoardView.cellSizeFor(cssW, cssH, w, h, orientation, this.board.players.length > 2)); @@ -136,6 +204,63 @@ export class BoardView { this.updatePageOffset(); } + /** Triangle board, turned to face the players (see triTurn) and centred with its avatars. */ + private layoutTriangle(cssW: number, cssH: number): void { + const n = this.board.w; + const { at, min, max } = triPlace(n, this.orientation); + const s = Math.max(4, Math.floor(triScale(n, cssW, cssH, this.orientation))); + this.side = s; + this.cell = s * TRI_CELL; + const ox = cssW / 2 - ((min[0] + max[0]) / 2) * s; + const oy = cssH / 2 - ((min[1] + max[1]) / 2) * s; + const map = (p: Vec): Vec => { + const [x, y] = at(p); + return [ox + x * s, oy + y * s]; + }; + this.triMap = map; + + const size = this.board.size; + const cells = Array.from({ length: size }, (_, i) => triCell(n, i)); + this.tv = new Float32Array(size * 6); + this.tc = new Float32Array(size * 2); + this.tnb = new Int32Array(size * 3).fill(-1); + const same = (a: [number, number], b: [number, number]) => Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6; + for (let i = 0; i < size; i++) { + const { pts, c } = cells[i]; + pts.forEach((pt, k) => { + const [x, y] = map(pt); + this.tv[i * 6 + k * 2] = x; + this.tv[i * 6 + k * 2 + 1] = y; + }); + [this.tc[i * 2], this.tc[i * 2 + 1]] = map(c); + // Which neighbour lies across each side (for merging same-colour triangles). + for (const j of this.board.neighbors(i)) { + const other = cells[j].pts; + for (let k = 0; k < 3; k++) { + const a = pts[k]; + const b = pts[(k + 1) % 3]; + if (other.some((q) => same(q, a)) && other.some((q) => same(q, b))) this.tnb[i * 3 + k] = j; + } + } + } + this.outline = ([[n / 2, 0], [n, n * H3], [0, n * H3]] as [number, number][]).map(map); + } + + /** Rotation (degrees, clockwise) that makes a player's avatar face the middle of the board. */ + homeAngle(p: PlayerId): number { + if (this.board.shape === 'triangle') { + // "Up" for the avatar (sin θ, -cos θ) points into the board, across their side. + const [x, y] = turn(triEdge(this.board.w, p).inward, triTurn(this.orientation)); + return (Math.atan2(x, -y) * 180) / Math.PI; + } + const i = this.board.home[p]; + const col = this.pos[i * 2]; + const row = this.pos[i * 2 + 1]; + if (row === this.rows - 1) return 0; + if (row === 0) return 180; + return col === 0 ? 90 : 270; + } + updatePageOffset(): void { const r = this.canvas.getBoundingClientRect(); this.pageX = r.left; @@ -144,6 +269,7 @@ export class BoardView { /** Centre of a cell in canvas CSS px. */ cellCenter(i: number): [number, number] { + if (this.board.shape === 'triangle') return [this.tc[i * 2], this.tc[i * 2 + 1]]; return [this.ox + (this.pos[i * 2] + 0.5) * this.cell, this.oy + (this.pos[i * 2 + 1] + 0.5) * this.cell]; } @@ -154,6 +280,7 @@ export class BoardView { /** Where the avatar of a player sits (canvas CSS px): on the outer edge of their home cell. */ avatarAnchor(p: PlayerId): [number, number] { + if (this.board.shape === 'triangle') return this.triMap(triEdge(this.board.w, p).mid); const i = this.board.home[p]; const [cx, cy] = this.cellCenter(i); const half = this.cell / 2; @@ -197,9 +324,11 @@ export class BoardView { ctx.clearRect(0, 0, this.cssW, this.cssH); // Plate behind the cells. + const tri = board.shape === 'triangle'; const pad = Math.max(3, s * 0.12); ctx.fillStyle = PLATE; - roundRectPath(ctx, this.ox - pad, this.oy - pad, this.cols * s + pad * 2, this.rows * s + pad * 2, [s * 0.4, s * 0.4, s * 0.4, s * 0.4]); + if (tri) roundPolyPath(ctx, inflate(this.outline, pad), [this.side * 0.5, this.side * 0.5, this.side * 0.5]); + else roundRectPath(ctx, this.ox - pad, this.oy - pad, this.cols * s + pad * 2, this.rows * s + pad * 2, [s * 0.4, s * 0.4, s * 0.4, s * 0.4]); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 2; @@ -231,28 +360,25 @@ export class BoardView { // Static tiles first (merged into blobs), animated ones on top. for (let i = 0; i < n; i++) { if (this.animT[i] >= 0) continue; - const o = this.disp[i] as Owner; - const [x, y] = this.tileOrigin(i); - this.drawTile(x, y, o, o === 0 ? NO_EXT : this.extFor(i, o), 1, i); + this.paintCell(i, this.disp[i] as Owner, true, 1); } + const reach = tri ? this.side : s; for (let i = 0; i < n; i++) { const t = this.animT[i]; if (t < 0) continue; - const [x, y] = this.tileOrigin(i); const from = this.aFrom[i] as Owner; const to = this.aTo[i] as Owner; if (to === 0) { // Shrinking back to neutral: the colour drains away. - this.drawTile(x, y, 0, NO_EXT, 1, i); + this.paintCell(i, 0, false, 1); ctx.globalAlpha = 1 - t; - this.drawTile(x, y, from, NO_EXT, 1 - easeOutCubic(t) * 0.6, i); + this.paintCell(i, from, false, 1 - easeOutCubic(t) * 0.6); ctx.globalAlpha = 1; continue; } - this.drawTile(x, y, from, NO_EXT, 1, i); + this.paintCell(i, from, false, 1); // New colour spreads like paint from the neighbour it came from. - let cx = x + s / 2; - let cy = y + s / 2; + let [cx, cy] = this.cellCenter(i); const src = this.aSrc[i]; if (src >= 0) { const [sx, sy] = this.cellCenter(src); @@ -261,9 +387,9 @@ export class BoardView { } ctx.save(); ctx.beginPath(); - ctx.arc(cx, cy, s * 1.5 * easeOutCubic(clamp01(t * 1.25)), 0, Math.PI * 2); + ctx.arc(cx, cy, reach * 1.5 * easeOutCubic(clamp01(t * 1.25)), 0, Math.PI * 2); ctx.clip(); - this.drawTile(x, y, to, NO_EXT, 1 + 0.22 * Math.sin(Math.PI * t), i); + this.paintCell(i, to, false, 1 + 0.22 * Math.sin(Math.PI * t)); ctx.restore(); } this.drawSparkles(now); @@ -306,6 +432,95 @@ export class BoardView { this.onPaint({ idx: i, from: this.aFrom[i] as Owner, to: this.aTo[i] as Owner, x, y }); } + /** Draws one cell; `merge`: join it with same-colour neighbours (static cells only). */ + private paintCell(i: number, o: Owner, merge: boolean, scale: number): void { + if (this.board.shape === 'triangle') { + this.drawTri(i, o, merge && o !== 0, scale); + return; + } + const [x, y] = this.tileOrigin(i); + this.drawTile(x, y, o, merge && o !== 0 ? this.extFor(i, o) : NO_EXT, scale, i); + } + + /** + * One small triangle. Sides shared with a same-colour (static) neighbour + * reach right to the edge so the colour reads as one blob; the others are + * pulled in to leave a gap, with rounded corners where two of them meet. + */ + private drawTri(i: number, o: Owner, merge: boolean, scale: number): void { + const { ctx, side: s } = this; + const g = Math.max(1.5, s * 0.07); + const cx = this.tc[i * 2]; + const cy = this.tc[i * 2 + 1]; + const P: [number, number][] = [0, 1, 2].map((k) => [this.tv[i * 6 + k * 2], this.tv[i * 6 + k * 2 + 1]]); + const joined = [false, false, false]; + const lines: { ax: number; ay: number; ex: number; ey: number; nx: number; ny: number }[] = []; + for (let k = 0; k < 3; k++) { + const j = this.tnb[i * 3 + k]; + joined[k] = merge && j >= 0 && this.disp[j] === o && this.animT[j] < 0; + const [ax, ay] = P[k]; + const [bx, by] = P[(k + 1) % 3]; + const len = Math.hypot(bx - ax, by - ay); + const ex = (bx - ax) / len; + const ey = (by - ay) / len; + // Unit normal pointing into the triangle. + let nx = -ey; + let ny = ex; + if ((cx - ax) * nx + (cy - ay) * ny < 0) { + nx = -nx; + ny = -ny; + } + const d = joined[k] ? -0.6 : g / 2; // a hair of overlap hides seams between joined triangles + lines.push({ ax: ax + nx * d, ay: ay + ny * d, ex, ey, nx, ny }); + } + // Corner k sits between side k-1 and side k. + const V: [number, number][] = [0, 1, 2].map((k) => { + const l1 = lines[(k + 2) % 3]; + const l2 = lines[k]; + const den = l1.ex * l2.ey - l1.ey * l2.ex; + const t = ((l2.ax - l1.ax) * l2.ey - (l2.ay - l1.ay) * l2.ex) / den; + const x = l1.ax + l1.ex * t; + const y = l1.ay + l1.ey * t; + return [cx + (x - cx) * scale, cy + (y - cy) * scale]; + }); + const rad = s * 0.18 * scale; + const radii = [0, 1, 2].map((k) => (!joined[(k + 2) % 3] && !joined[k] ? rad : 0)); + if (o === 0) { + ctx.fillStyle = NEUTRAL; + roundPolyPath(ctx, V, radii); + ctx.fill(); + ctx.fillStyle = NEUTRAL_EDGE; + ctx.beginPath(); + ctx.arc(cx, cy, s * 0.06, 0, Math.PI * 2); + ctx.fill(); + return; + } + const c = this.colors[o as PlayerId]!; + ctx.fillStyle = c.main; + roundPolyPath(ctx, V, radii); + ctx.fill(); + if (this.aOn[i] && this.animT[i] >= 0) { + ctx.fillStyle = `rgba(255,255,255,${(0.35 * (1 - this.animT[i])).toFixed(3)})`; + ctx.fill(); + } + // Chunky "candy" depth along the free sides that face down. + const shade = [0, 1, 2].filter((k) => !joined[k] && -lines[k].ny > 0.3); + if (shade.length) { + ctx.save(); + ctx.clip(); + ctx.strokeStyle = c.dark; + ctx.globalAlpha *= 0.55; + ctx.lineWidth = Math.max(4, s * 0.24); + ctx.beginPath(); + for (const k of shade) { + ctx.moveTo(...V[k]); + ctx.lineTo(...V[(k + 1) % 3]); + } + ctx.stroke(); + ctx.restore(); + } + } + private tileOrigin(i: number): [number, number] { return [this.ox + this.pos[i * 2] * this.cell, this.oy + this.pos[i * 2 + 1] * this.cell]; } @@ -404,3 +619,31 @@ function roundRectPath( else ctx.lineTo(x, y); ctx.closePath(); } + +/** Closed polygon with a rounding radius per corner (0 = sharp). */ +function roundPolyPath(ctx: CanvasRenderingContext2D, pts: [number, number][], radii: number[]): void { + const n = pts.length; + const [lx, ly] = pts[n - 1]; + const [fx, fy] = pts[0]; + ctx.beginPath(); + ctx.moveTo((lx + fx) / 2, (ly + fy) / 2); + for (let k = 0; k < n; k++) { + const [x1, y1] = pts[k]; + const [x2, y2] = pts[(k + 1) % n]; + ctx.arcTo(x1, y1, x2, y2, radii[k]); + } + ctx.closePath(); +} + +/** Pushes every side of a convex polygon outwards by `d`. */ +function inflate(pts: [number, number][], d: number): [number, number][] { + const n = pts.length; + const cx = pts.reduce((s, p) => s + p[0], 0) / n; + const cy = pts.reduce((s, p) => s + p[1], 0) / n; + // For a regular polygon, each corner moves away from the centre by d / cos(π / n). + const k = d / Math.cos(Math.PI / n); + return pts.map(([x, y]) => { + const len = Math.hypot(x - cx, y - cy) || 1; + return [x + ((x - cx) / len) * k, y + ((y - cy) / len) * k]; + }); +} diff --git a/src/ui/game.ts b/src/ui/game.ts index 2a0f708..2db6e2e 100644 --- a/src/ui/game.ts +++ b/src/ui/game.ts @@ -9,7 +9,7 @@ import { sfx, vibrate } from '../audio'; import type { Settings } from '../settings'; import { h, hasKeyboard, isLandscape, onPress, pulse } from './dom'; import { ICONS } from './icons'; -import { ANGLE, SIDE, contentBox, frame, inward, measureDepth, overlayFrame, placeSeat } from './seats'; +import { ANGLE, contentBox, frame, measureDepth, overlayFrame, placeSeat, sideOf, type Side } from './seats'; type State = 'countdown' | 'playing' | 'paused' | 'ending' | 'over'; @@ -88,6 +88,8 @@ export class GameScreen { private elapsed = 0; private lastSecond = -1; private orientation: Orientation; + /** 3 players on a tablet lying sideways: player 2 on the right and the triangle pointing up. */ + private turned = false; private readonly timers = new Set(); private finale = false; /** Knocked-out players, first one out first. */ @@ -126,10 +128,22 @@ export class GameScreen { /** Rotation that makes things face a player (degrees, clockwise). */ private angleOf(id: PlayerId): number { - if (this.multi) return ANGLE[SIDE[id]]; + // Around the screen they face their own side of the board (on a triangle, a slanted one). + if (this.multi) return this.view ? this.view.homeAngle(id) : ANGLE[this.seat(id)]; return this.orientation === 'portrait' && id === 2 ? 180 : 0; } + /** Which side of the screen a player sits on (3-4 players). */ + private seat(id: PlayerId): Side { + return sideOf(id, this.players.length, this.turned); + } + + /** How the board is turned on screen; the triangle (3 players) points up when the tablet lies sideways. */ + private boardTurn(): Orientation { + if (!this.multi) return this.orientation; + return this.turned ? 'landscape' : 'portrait'; + } + private makePlayer(id: PlayerId, ids: PlayerId[]): PlayerRt { const setup = this.settings.players[id]; const color = COLORS[setup.color]; @@ -180,11 +194,6 @@ export class GameScreen { const avatar = new Avatar(setup.avatar); const avatarWrap = h('div', { class: `avatar-wrap p${id}` }, avatar.el); applyColorVars(avatarWrap, color); - if (this.multi) { - // Avatar and messages face the player sitting on that side. - avatarWrap.style.setProperty('rotate', `${this.angleOf(id)}deg`); - floats.style.setProperty('rotate', `${this.angleOf(id)}deg`); - } return { id, color, difficulty: setup.difficulty, avatar, avatarWrap, panel, qcard, qtext, @@ -198,8 +207,14 @@ export class GameScreen { start(): void { if (this.multi) this.layoutSeats(); const r = this.arena.getBoundingClientRect(); - const [w, hCells] = this.multi ? GameScreen.multiBoardSize(r.width, r.height) : this.boardSize(r.width, r.height); - this.board = new Board(w, hCells, this.players.map((p) => p.id)); + const ids = this.players.map((p) => p.id); + if (ids.length === 3) { + // Three players: a triangle, one side facing each of them. + this.board = Board.triangle(BoardView.triangleSizeFor(r.width, r.height, this.boardTurn()), ids); + } else { + const [w, hCells] = this.multi ? GameScreen.multiBoardSize(r.width, r.height) : this.boardSize(r.width, r.height); + this.board = new Board(w, hCells, ids); + } const colors: Partial> = {}; for (const p of this.players) colors[p.id] = p.color; this.view = new BoardView(this.board, colors); @@ -221,7 +236,7 @@ export class GameScreen { return [w, Math.max(8, Math.min(16, Math.round(along / s0 - AVATAR_MARGIN * 2)))]; } - /** 3-4 players: odd sizes so the avatars on every side sit in the middle of their edge. */ + /** 4 players: odd sizes so the avatars on every side sit in the middle of their edge. */ static multiBoardSize(width: number, height: number): [number, number] { const short = Math.min(width, height); const long = Math.max(width, height); @@ -245,7 +260,7 @@ export class GameScreen { if (!this.view) return; const o = this.orientation; const r = this.arena.getBoundingClientRect(); - this.view.layout(r.width, r.height, o); + this.view.layout(r.width, r.height, this.boardTurn()); const size = this.view.cell * 1.7; const unit = Math.max(15, Math.min(30, this.view.cell * 0.56)); for (const p of this.players) { @@ -259,9 +274,13 @@ export class GameScreen { let fx = x; let fy = y; if (this.multi) { - const [ux, uy] = inward(SIDE[p.id]); - fx += ux * reach; - fy += uy * reach; + // Avatar and messages face the player, growing towards the middle of the board. + const deg = this.angleOf(p.id); + const a = (deg * Math.PI) / 180; + fx += Math.sin(a) * reach; + fy -= Math.cos(a) * reach; + p.avatarWrap.style.setProperty('rotate', `${deg}deg`); + p.floats.style.setProperty('rotate', `${deg}deg`); } else if (o === 'portrait') fy = p.id === 1 ? y - reach : y + reach; else fx = p.id === 1 ? x + reach : x - reach; const z = p.floats; @@ -277,13 +296,14 @@ export class GameScreen { /** 3-4 players: a panel on each side of the screen, facing its player, and the board in the middle. */ private layoutSeats(): void { const box = contentBox(this.el); + this.turned = this.players.length === 3 && box.w > box.h; this.el.style.setProperty('--vm', `${Math.min(box.w, box.h) / 100}px`); - const panels = new Map(this.players.map((p) => [SIDE[p.id], p.panel])); + const panels = new Map(this.players.map((p) => [this.seat(p.id), p.panel])); const { strips, middle } = frame(box, (side, len) => { const el = panels.get(side); return el ? measureDepth(el, len) : 0; }); - for (const p of this.players) placeSeat(p.panel, SIDE[p.id], strips[SIDE[p.id]]); + for (const p of this.players) placeSeat(p.panel, this.seat(p.id), strips[this.seat(p.id)]); const s = this.arena.style; s.left = `${middle.x}px`; s.top = `${middle.y}px`; @@ -830,7 +850,7 @@ export class GameScreen { if (!halves.length) return; const strips = overlayFrame(contentBox(this.el)); for (const el of halves) { - const side = SIDE[Number(el.dataset.id) as PlayerId]; + const side = this.seat(Number(el.dataset.id) as PlayerId); placeSeat(el, side, strips[side]); } } diff --git a/src/ui/menu.ts b/src/ui/menu.ts index 1872a1b..a2c5eac 100644 --- a/src/ui/menu.ts +++ b/src/ui/menu.ts @@ -7,7 +7,7 @@ import { isSoundEnabled, setSoundEnabled, sfx, unlockAudio } from '../audio'; import { h, isLandscape, isLargeScreen, pulse } from './dom'; import { ICONS } from './icons'; import { canRequestFullscreen, enterFullscreen, isFullscreen, isIOS } from './fullscreen'; -import { SIDE, clearSeat, contentBox, frame, measureDepth, placeSeat } from './seats'; +import { clearSeat, contentBox, frame, measureDepth, placeSeat, sideOf } from './seats'; export interface MenuCallbacks { onStart(): void; @@ -272,12 +272,13 @@ export class MenuScreen { // 3-4 players: each setup on its own side of the screen, facing its player; logo and options in the middle. const box = contentBox(this.el); this.el.style.setProperty('--vm', `${Math.min(box.w, box.h) / 100}px`); - const roots = new Map(this.active().map((id) => [SIDE[id], this.ui[id].root])); + const side = (id: PlayerId) => sideOf(id, count, box.w > box.h); + const roots = new Map(this.active().map((id) => [side(id), this.ui[id].root])); const { strips, middle } = frame(box, (side, len) => { const el = roots.get(side); return el ? measureDepth(el, len) : 0; }); - for (const id of this.active()) placeSeat(this.ui[id].root, SIDE[id], strips[SIDE[id]]); + for (const id of this.active()) placeSeat(this.ui[id].root, side(id), strips[side(id)]); c.left = `${middle.x}px`; c.top = `${middle.y}px`; c.width = `${middle.w}px`; @@ -346,7 +347,7 @@ export class MenuScreen { h('li', { html: '10 acertos seguidos enviam um 😈 diabinho: o adversário recebe uma conta bem mais difícil. Quem a acertar ganha a dobrar!' }), h('li', { html: 'O nível escolhe as contas: 🐣 + −, 🙂 + − ×, 😎 + − × ÷, 🔥 e 🧠 tudo. A cada 4 acertos os números ficam maiores.' }), h('li', { html: 'Ganha quem pintar o tabuleiro todo — ou quem tiver mais quando o tempo acabar.' }), - h('li', { html: 'Num tablet podem jogar até 4 (botão 👥): um em cada lado do ecrã. Quem fica sem território sai e os outros continuam; o 😈 vai para o adversário com mais território.' }), + h('li', { html: 'Num tablet podem jogar até 4 (botão 👥): um em cada lado do ecrã — com 3, o tabuleiro é um triângulo 🔺. Quem fica sem território sai e os outros continuam; o 😈 vai para o adversário com mais território.' }), ), h('p', { class: 'help-tip', text: 'Dica: escolham níveis diferentes para jogar miúdos contra graúdos!' }), close, diff --git a/src/ui/seats.ts b/src/ui/seats.ts index a0e170b..5a3814d 100644 --- a/src/ui/seats.ts +++ b/src/ui/seats.ts @@ -15,6 +15,15 @@ export type Side = 'bottom' | 'top' | 'left' | 'right'; /** Where each player sits. */ export const SIDE: Record = { 1: 'bottom', 2: 'top', 3: 'left', 4: 'right' }; +/** + * Where a player sits. With three on a tablet lying sideways, player 2 moves + * from the top to the right: the triangle board then points up and gets the + * room (going round the board, the players stay in the same order). + */ +export function sideOf(id: PlayerId, count: number, landscape: boolean): Side { + return count === 3 && landscape && id === 2 ? 'right' : SIDE[id]; +} + /** Clockwise rotation (degrees) that turns content to face a player sitting on that side. */ export const ANGLE: Record = { bottom: 0, top: 180, left: 90, right: 270 }; @@ -25,20 +34,6 @@ export interface Rect { h: number; } -/** Unit vector pointing from a side of the screen towards its middle. */ -export function inward(side: Side): [number, number] { - switch (side) { - case 'bottom': - return [0, -1]; - case 'top': - return [0, 1]; - case 'left': - return [1, 0]; - case 'right': - return [-1, 0]; - } -} - /** The content area of a screen element (inside its safe-area padding), in the element's own coordinates. */ export function contentBox(el: HTMLElement): Rect { const cs = getComputedStyle(el); diff --git a/tests/board.test.ts b/tests/board.test.ts index e5ec940..d75086a 100644 --- a/tests/board.test.ts +++ b/tests/board.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Board, playerIds } from '../src/board'; +import { Board, playerIds, triCell, triEdge } from '../src/board'; const seq = (vals: number[]) => { let i = 0; @@ -126,3 +126,64 @@ describe('Board with 3-4 players', () => { expect(playerIds(0)).toEqual([1, 2]); }); }); + +describe('triangle board (3 players)', () => { + const N = 13; + const shared = (a: number[][], b: number[][]) => + a.filter((p) => b.some((q) => Math.abs(p[0] - q[0]) < 1e-9 && Math.abs(p[1] - q[1]) < 1e-9)).length; + + it('is cut into n² small triangles, each touching its neighbours along a whole side', () => { + const b = Board.triangle(N); + expect(b.size).toBe(N * N); + const degrees = [0, 0, 0, 0]; + for (let i = 0; i < b.size; i++) { + degrees[b.neighbors(i).length]++; + for (const j of b.neighbors(i)) { + expect(b.neighbors(j)).toContain(i); + expect(shared(triCell(N, i).pts, triCell(N, j).pts)).toBe(2); + } + } + // 3 corners, the rest of the outline, and everything inside. + expect(degrees).toEqual([0, 3, 3 * N - 6, N * N - 3 * N + 3]); + }); + + it('puts each player in the middle of their own side, with the same small strip', () => { + const b = Board.triangle(N); + const dist = (a: number[], c: number[]) => Math.hypot(a[0] - c[0], a[1] - c[1]); + for (const p of [1, 2, 3] as const) { + const { mid } = triEdge(N, p); + expect(dist(triCell(N, b.home[p]).c, mid)).toBeCloseTo(Math.sqrt(3) / 6); // the triangle sitting on the side + expect(b.count(p)).toBe(5); + expect(b.cells[b.home[p]]).toBe(p); + } + expect(b.home[4]).toBe(-1); + expect(() => Board.triangle(N, [1, 2, 3, 4])).toThrow(); + }); + + it('players grow away from their side, towards the middle', () => { + for (const p of [1, 2, 3] as const) { + const b = Board.triangle(N); + const { mid, inward } = triEdge(N, p); + const depth = (i: number) => { + const [x, y] = triCell(N, i).c; + return (x - mid[0]) * inward[0] + (y - mid[1]) * inward[1]; + }; + const before = Math.max(...[...b.cells.keys()].filter((i) => b.cells[i] === p).map(depth)); + const grown = b.expand(p, 12, () => 0.5); + expect(grown).toHaveLength(12); + expect(Math.max(...grown.map((c) => depth(c.idx)))).toBeGreaterThan(before + 1); + } + }); + + it('knock-outs work like on the square board', () => { + const b = Board.triangle(9); + b.cells.fill(1); + b.cells[b.home[2]] = 2; + b.cells[b.home[3]] = 3; + const ko = b.expand(1, 10); + expect(ko.map((c) => c.from).sort()).toEqual([2, 3]); + expect(b.alive()).toEqual([1]); + b.flood(1); + expect(b.count(1)).toBe(81); + }); +}); diff --git a/tests/seats.test.ts b/tests/seats.test.ts index 1cf8eb2..1ec57b1 100644 --- a/tests/seats.test.ts +++ b/tests/seats.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { frame, overlayFrame, type Rect, type Side } from '../src/ui/seats'; +import { frame, overlayFrame, sideOf, type Rect, type Side } from '../src/ui/seats'; const SIDES: Side[] = ['bottom', 'top', 'left', 'right']; @@ -65,3 +65,12 @@ describe('seat layout (3-4 players around a tablet)', () => { } }); }); + +describe('who sits where', () => { + it('one player per side; with three on a tablet lying sideways, player 2 moves to the right', () => { + expect([1, 2, 3, 4].map((id) => sideOf(id as 1, 4, false))).toEqual(['bottom', 'top', 'left', 'right']); + expect([1, 2, 3, 4].map((id) => sideOf(id as 1, 4, true))).toEqual(['bottom', 'top', 'left', 'right']); + expect([1, 2, 3].map((id) => sideOf(id as 1, 3, false))).toEqual(['bottom', 'top', 'left']); + expect([1, 2, 3].map((id) => sideOf(id as 1, 3, true))).toEqual(['bottom', 'right', 'left']); + }); +});