chore: восстановление репозитория из снапшота v0.3.1

Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sab.code.lab 2026-08-07 09:34:02 +02:00
commit 1c85091186
184 changed files with 33303 additions and 0 deletions

View file

@ -0,0 +1,14 @@
{
"name": "@go-learn/board",
"version": "0.1.0",
"private": true,
"description": "Отрисовка доски Го на Canvas 2D: чистая геометрия, рендер состояния, редьюсер тач-ввода с подтверждением, тонкий DOM-адаптер.",
"type": "module",
"main": "src/index.ts",
"scripts": {
"test": "vitest run"
},
"dependencies": {
"@go-learn/core": "0.1.0"
}
}

43
packages/board/src/dom.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* Тонкий DOM-адаптер: настройка канвы под devicePixelRatio и перевод
* pointer-событий в InputEvent. Логики здесь нет вся в geometry/input.
* Юнит-тестами не покрывается (проверка смоук на этапе 5).
*/
import type { InputEvent } from './input.js';
/**
* Выставляет canvas.width/height = cssSize × devicePixelRatio и
* ctx.setTransform(dpr, ), чтобы рисовать в логических CSS px.
* Возвращает применённый dpr.
*/
export function setupCanvas(canvas: HTMLCanvasElement, cssSize: number): number {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(cssSize * dpr);
canvas.height = Math.round(cssSize * dpr);
canvas.style.width = `${cssSize}px`;
canvas.style.height = `${cssSize}px`;
canvas.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0);
return dpr;
}
/**
* Подписывается на pointerdown канвы и вызывает onEvent с событием 'tap'
* в координатах канвы (CSS px). Возвращает функцию отписки.
*/
export function attachPointerInput(
canvas: HTMLCanvasElement,
onEvent: (event: InputEvent) => void,
): () => void {
const handler = (event: PointerEvent): void => {
const rect = canvas.getBoundingClientRect();
onEvent({
type: 'tap',
px: event.clientX - rect.left,
py: event.clientY - rect.top,
});
};
canvas.addEventListener('pointerdown', handler);
return (): void => {
canvas.removeEventListener('pointerdown', handler);
};
}

View file

@ -0,0 +1,124 @@
/**
* Тесты геометрии: хоси, roundtrip pixelToPointpointToPixel, отклонение
* курсора дальше половины клетки, поля с координатами и без.
*/
import { describe, expect, it } from 'vitest';
import type { BoardSize, Point } from '@go-learn/core';
import { computeGeometry, hoshiPoints, pixelToPoint, pointToPixel } from './geometry.js';
function hasPoint(points: readonly Point[], x: number, y: number): boolean {
return points.some((p) => p.x === x && p.y === y);
}
describe('hoshiPoints', () => {
it('19×19: 9 точек, углы на линиях 3-3 (0-based)', () => {
const points = hoshiPoints(19);
expect(points).toHaveLength(9);
for (const x of [3, 9, 15]) {
for (const y of [3, 9, 15]) expect(hasPoint(points, x, y)).toBe(true);
}
});
it('13×13: 5 точек — углы 3-3 и центр', () => {
const points = hoshiPoints(13);
expect(points).toHaveLength(5);
for (const [x, y] of [
[3, 3],
[9, 3],
[6, 6],
[3, 9],
[9, 9],
]) {
expect(hasPoint(points, x ?? 0, y ?? 0)).toBe(true);
}
});
it('9×9: 5 точек — углы 2-2 и центр', () => {
const points = hoshiPoints(9);
expect(points).toHaveLength(5);
for (const [x, y] of [
[2, 2],
[6, 2],
[4, 4],
[2, 6],
[6, 6],
]) {
expect(hasPoint(points, x ?? 0, y ?? 0)).toBe(true);
}
});
});
describe('computeGeometry', () => {
it('сетка + два поля ровно заполняют квадрат канвы', () => {
for (const size of [9, 13, 19] as const) {
for (const show of [true, false]) {
const geo = computeGeometry(size, 600, show);
expect(geo.padding * 2 + geo.cell * (size - 1)).toBeCloseTo(600, 10);
}
}
});
it('padding с координатами больше, чем без', () => {
const withCoords = computeGeometry(19, 600, true);
const without = computeGeometry(19, 600, false);
expect(withCoords.padding).toBeGreaterThan(without.padding);
});
it('крайний камень не обрезается: padding ≥ cell/2', () => {
for (const size of [9, 13, 19] as const) {
expect(computeGeometry(size, 300, false).padding).toBeGreaterThanOrEqual(
computeGeometry(size, 300, false).cell / 2,
);
}
});
});
describe('roundtrip pixelToPoint∘pointToPixel', () => {
it('каждое пересечение возвращается в себя', () => {
for (const size of [9, 13, 19] as const) {
const geo = computeGeometry(size, 570, true);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const px = pointToPixel(geo, { x, y });
expect(pixelToPoint(geo, px.x, px.y)).toEqual({ x, y });
}
}
}
});
it('сдвиг меньше половины клетки — ближайшее пересечение', () => {
const geo = computeGeometry(9, 450, false);
const center = pointToPixel(geo, { x: 4, y: 4 });
const hit = pixelToPoint(geo, center.x + geo.cell * 0.4, center.y - geo.cell * 0.4);
expect(hit).toEqual({ x: 4, y: 4 });
});
});
describe('pixelToPoint: отклонения', () => {
const geo = computeGeometry(9, 450, false);
it('дальше половины клетки за краем доски — null', () => {
const corner = pointToPixel(geo, { x: 8, y: 8 });
expect(pixelToPoint(geo, corner.x + geo.cell * 0.6, corner.y)).toBeNull();
expect(pixelToPoint(geo, corner.x, corner.y + geo.cell * 0.6)).toBeNull();
});
it('за пределами канвы — null', () => {
expect(pixelToPoint(geo, -10, 100)).toBeNull();
expect(pixelToPoint(geo, 100, geo.pixelSize + 10)).toBeNull();
});
it('в пределах половины клетки от крайнего пересечения — не null', () => {
const edge = pointToPixel(geo, { x: 8, y: 0 });
expect(pixelToPoint(geo, edge.x + geo.cell * 0.4, edge.y)).toEqual({ x: 8, y: 0 });
});
});
describe('размер доски в геометрии', () => {
it('size пробрасывается из аргумента', () => {
const sizes: BoardSize[] = [9, 13, 19];
for (const size of sizes) {
expect(computeGeometry(size, 500, true).size).toBe(size);
}
});
});

View file

@ -0,0 +1,71 @@
/**
* Геометрия доски: перевод пересечений в пиксели канвы и обратно.
* Чистый модуль: без DOM, без состояния, без Date.now()/Math.random().
*
* Канва логический квадрат pixelSize×pixelSize (CSS px); сетка занимает
* (size1)·cell, остаток поровну уходит в поля padding с четырёх сторон.
*/
import type { BoardSize, Point } from '@go-learn/core';
export interface BoardGeometry {
readonly size: BoardSize;
readonly pixelSize: number; // логический квадрат канвы в CSS px
readonly padding: number; // поля (под координаты, если включены)
readonly cell: number; // шаг сетки в CSS px
}
/**
* Геометрия доски под квадрат pixelSize.
* Поля считаются от шага сетки: с координатами целая клетка (место под
* буквы/числа), без полклетки с запасом, чтобы крайние камни не резались.
*/
export function computeGeometry(
size: BoardSize,
pixelSize: number,
showCoordinates: boolean,
): BoardGeometry {
const marginCells = showCoordinates ? 1 : 0.55;
const cell = pixelSize / (size - 1 + marginCells * 2);
const padding = marginCells * cell;
return { size, pixelSize, padding, cell };
}
/** Центр пересечения в CSS px (от левого верхнего угла канвы). */
export function pointToPixel(geo: BoardGeometry, p: Point): { x: number; y: number } {
return { x: geo.padding + p.x * geo.cell, y: geo.padding + p.y * geo.cell };
}
/**
* Ближайшее пересечение к точке касания; null курсор вне доски либо
* дальше половины клетки от любого пересечения.
*/
export function pixelToPoint(geo: BoardGeometry, px: number, py: number): Point | null {
const x = Math.round((px - geo.padding) / geo.cell);
const y = Math.round((py - geo.padding) / geo.cell);
if (x < 0 || x >= geo.size || y < 0 || y >= geo.size) return null;
const dx = Math.abs(px - (geo.padding + x * geo.cell));
const dy = Math.abs(py - (geo.padding + y * geo.cell));
if (dx > geo.cell / 2 || dy > geo.cell / 2) return null;
return { x, y };
}
/**
* Хоси (звёздные пункты): 19×19 9 точек (линии 3/9/15 в 0-based),
* 13×13 и 9×9 по 5 (углы + центр).
*/
export function hoshiPoints(size: BoardSize): readonly Point[] {
if (size === 19) {
const lines = [3, 9, 15];
return lines.flatMap((y) => lines.map((x) => ({ x, y })));
}
const edge = size === 13 ? 3 : 2;
const center = (size - 1) / 2;
const far = size - 1 - edge;
return [
{ x: edge, y: edge },
{ x: far, y: edge },
{ x: center, y: center },
{ x: edge, y: far },
{ x: far, y: far },
];
}

View file

@ -0,0 +1,13 @@
/**
* Публичный API пакета доски (контракт docs/INTERFACES.md).
* Геометрия, ввод и рендер чистые функции без DOM; DOM только dom.ts.
* Типа PointKey в @go-learn/core нет: ключи множеств/карт string формата
* pointKey ("x,y"), см. комментарии у RenderOptions.
*/
export type { BoardGeometry } from './geometry.js';
export { computeGeometry, hoshiPoints, pixelToPoint, pointToPixel } from './geometry.js';
export type { BoardTheme, RenderOptions, TerritoryMap } from './render.js';
export { DEAD_STONE_ALPHA, render } from './render.js';
export type { InputEvent, InputResult, InputState } from './input.js';
export { reduceInput } from './input.js';
export { attachPointerInput, setupCanvas } from './dom.js';

View file

@ -0,0 +1,79 @@
/**
* Тесты редьюсера тач-ввода: вся таблица переходов.
* tap pixelToPoint; pending/commit/cancel/перенос по контракту.
*/
import { describe, expect, it } from 'vitest';
import type { Point } from '@go-learn/core';
import { computeGeometry, pointToPixel } from './geometry.js';
import type { InputEvent, InputState } from './input.js';
import { reduceInput } from './input.js';
const geo = computeGeometry(9, 450, false);
const IDLE: InputState = { pending: null };
function tap(point: Point): InputEvent {
const { x, y } = pointToPixel(geo, point);
return { type: 'tap', px: x, py: y };
}
const A: Point = { x: 3, y: 3 };
const B: Point = { x: 5, y: 6 };
describe('reduceInput: tap', () => {
it('тап при pending=null → pending=точка, commit=null', () => {
const result = reduceInput(IDLE, tap(A), geo);
expect(result.commit).toBeNull();
expect(result.state.pending).toEqual(A);
});
it('повторный тап по той же точке → commit=точка, pending=null', () => {
const result = reduceInput({ pending: A }, tap(A), geo);
expect(result.commit).toEqual(A);
expect(result.state.pending).toBeNull();
});
it('тап по другой точке → перенос pending, commit=null', () => {
const result = reduceInput({ pending: A }, tap(B), geo);
expect(result.commit).toBeNull();
expect(result.state.pending).toEqual(B);
});
it('тап мимо доски → состояние не меняется, commit=null', () => {
const miss: InputEvent = { type: 'tap', px: -20, py: 450 + 20 };
const fromIdle = reduceInput(IDLE, miss, geo);
expect(fromIdle.state).toBe(IDLE);
expect(fromIdle.commit).toBeNull();
const withPending: InputState = { pending: A };
const fromPending = reduceInput(withPending, miss, geo);
expect(fromPending.state).toBe(withPending);
expect(fromPending.commit).toBeNull();
});
});
describe('reduceInput: confirm', () => {
it('confirm при pending → commit=pending, pending=null', () => {
const result = reduceInput({ pending: A }, { type: 'confirm' }, geo);
expect(result.commit).toEqual(A);
expect(result.state.pending).toBeNull();
});
it('confirm без pending → commit=null, pending=null', () => {
const result = reduceInput(IDLE, { type: 'confirm' }, geo);
expect(result.commit).toBeNull();
expect(result.state.pending).toBeNull();
});
});
describe('reduceInput: cancel', () => {
it('cancel при pending → сброс, commit=null', () => {
const result = reduceInput({ pending: A }, { type: 'cancel' }, geo);
expect(result.commit).toBeNull();
expect(result.state.pending).toBeNull();
});
it('cancel без pending → commit=null, pending=null', () => {
const result = reduceInput(IDLE, { type: 'cancel' }, geo);
expect(result.commit).toBeNull();
expect(result.state.pending).toBeNull();
});
});

View file

@ -0,0 +1,38 @@
/**
* Тач-ввод с подтверждением: чистый редьюсер, без DOM.
* Первый тап фантом (pending); второй тап по той же точке или confirm
* ход (commit); тап по другой точке перенос фантома; cancel сброс.
*/
import type { Point } from '@go-learn/core';
import type { BoardGeometry } from './geometry.js';
import { pixelToPoint } from './geometry.js';
export type InputEvent =
| { readonly type: 'tap'; readonly px: number; readonly py: number }
| { readonly type: 'confirm' }
| { readonly type: 'cancel' };
export interface InputState {
readonly pending: Point | null;
}
export interface InputResult {
readonly state: InputState;
readonly commit: Point | null; // не-null — пользователь подтвердил ход
}
function samePoint(a: Point, b: Point): boolean {
return a.x === b.x && a.y === b.y;
}
/** Единственная точка изменения состояния ввода; неизменяемый переход. */
export function reduceInput(state: InputState, event: InputEvent, geo: BoardGeometry): InputResult {
if (event.type === 'cancel') return { state: { pending: null }, commit: null };
if (event.type === 'confirm') return { state: { pending: null }, commit: state.pending };
const point = pixelToPoint(geo, event.px, event.py);
if (point === null) return { state, commit: null };
if (state.pending !== null && samePoint(state.pending, point)) {
return { state: { pending: null }, commit: point };
}
return { state: { pending: point }, commit: null };
}

View file

@ -0,0 +1,254 @@
/**
* Тесты рендера: мок CanvasRenderingContext2D с записью вызовов.
* Проверяем только инварианты, не каждый вызов:
* число камней = числу непустых клеток BoardState;
* фантом рисуется с globalAlpha = phantomOpacity;
* метка последнего хода только при lastMove;
* подписи LB и фигуры TR/SQ/CR приходят из markup;
* fillRect-заливка только фон и territory-ключи;
* мёртвые камни рисуются с пониженной альфой (DEAD_STONE_ALPHA).
*/
import { describe, expect, it } from 'vitest';
import type { BoardState, NodeMarkup, Point } from '@go-learn/core';
import { applyMove, createBoard } from '@go-learn/core';
import type { BoardTheme, RenderOptions } from './render.js';
import { DEAD_STONE_ALPHA, render } from './render.js';
interface PathOp {
readonly kind: 'arc' | 'moveTo' | 'lineTo';
}
interface PaintRecord {
readonly color: string;
readonly globalAlpha: number;
readonly path: readonly PathOp[];
}
interface RectRecord {
readonly x: number;
readonly y: number;
readonly w: number;
readonly h: number;
readonly color: string;
}
interface TextRecord {
readonly text: string;
readonly x: number;
readonly y: number;
readonly color: string;
readonly font: string;
}
/** Рекордер вызовов CanvasRenderingContext2D (только нужный рендеру набор). */
class CtxRecorder {
fillStyle = '#000000';
strokeStyle = '#000000';
globalAlpha = 1;
lineWidth = 1;
font = '10px sans-serif';
textAlign = 'start';
textBaseline = 'alphabetic';
readonly canvas = { width: 600, height: 600 } as HTMLCanvasElement;
readonly fills: PaintRecord[] = [];
readonly strokes: PaintRecord[] = [];
readonly fillRects: RectRecord[] = [];
readonly strokeRects: RectRecord[] = [];
readonly texts: TextRecord[] = [];
saves = 0;
restores = 0;
private path: PathOp[] = [];
private readonly stack: number[] = [];
save(): void {
this.saves += 1;
this.stack.push(this.globalAlpha);
}
restore(): void {
this.restores += 1;
this.globalAlpha = this.stack.pop() ?? 1;
}
setTransform(): void {}
scale(): void {}
setLineDash(): void {}
beginPath(): void {
this.path = [];
}
arc(): void {
this.path.push({ kind: 'arc' });
}
moveTo(): void {
this.path.push({ kind: 'moveTo' });
}
lineTo(): void {
this.path.push({ kind: 'lineTo' });
}
getTransform(): DOMMatrix {
return { a: 1 } as DOMMatrix;
}
fill(): void {
this.fills.push({ color: this.fillStyle, globalAlpha: this.globalAlpha, path: this.path });
}
stroke(): void {
this.strokes.push({
color: this.strokeStyle,
globalAlpha: this.globalAlpha,
path: this.path,
});
}
fillRect(x: number, y: number, w: number, h: number): void {
this.fillRects.push({ x, y, w, h, color: this.fillStyle });
}
strokeRect(x: number, y: number, w: number, h: number): void {
this.strokeRects.push({ x, y, w, h, color: this.strokeStyle });
}
fillText(text: string, x: number, y: number): void {
this.texts.push({ text, x, y, color: this.fillStyle, font: this.font });
}
}
/** Различимые цвета для инвариантов (остальное — дефолтная тёмная тема). */
const THEME: Partial<BoardTheme> = {
blackStone: '#111111',
whiteStone: '#eeeeee',
phantomOpacity: 0.4,
lastMoveMarker: '#ff00ff',
markupColor: '#00ff00',
territoryBlack: 'rgba(1, 2, 3, 0.5)',
territoryWhite: 'rgba(4, 5, 6, 0.5)',
};
function play(state: BoardState, point: Point): BoardState {
const result = applyMove(state, { kind: 'play', color: state.toPlay, point });
if (!result.ok) throw new Error(result.error);
return result.state;
}
/** Позиция 9×9 с тремя камнями: чёрные (2,2) и (4,4), белый (6,2). */
function threeStones(): BoardState {
let state = createBoard(9);
state = play(state, { x: 2, y: 2 });
state = play(state, { x: 6, y: 2 });
return play(state, { x: 4, y: 4 });
}
function draw(state: BoardState, options: RenderOptions = {}): CtxRecorder {
const rec = new CtxRecorder();
render(rec as unknown as CanvasRenderingContext2D, state, {
theme: THEME,
...options,
});
return rec;
}
function stoneFills(rec: CtxRecorder, alpha: number): PaintRecord[] {
return rec.fills.filter(
(f) => (f.color === '#111111' || f.color === '#eeeeee') && f.globalAlpha === alpha,
);
}
describe('render: камни', () => {
it('число нарисованных камней = числу непустых клеток', () => {
const rec = draw(threeStones());
expect(stoneFills(rec, 1)).toHaveLength(3);
expect(rec.saves).toBe(rec.restores);
});
it('мёртвые камни приглушены альфой DEAD_STONE_ALPHA', () => {
const rec = draw(threeStones(), { dead: new Set(['2,2', '6,2']) });
expect(stoneFills(rec, DEAD_STONE_ALPHA)).toHaveLength(2);
expect(stoneFills(rec, 1)).toHaveLength(1);
});
});
describe('render: фантом и последний ход', () => {
it('фантом рисуется с globalAlpha = phantomOpacity', () => {
const rec = draw(threeStones(), { phantom: { point: { x: 0, y: 0 }, color: 'black' } });
const phantom = rec.fills.filter((f) => f.color === '#111111' && f.globalAlpha === 0.4);
expect(phantom).toHaveLength(1);
expect(stoneFills(rec, 1)).toHaveLength(3);
});
it('метка последнего хода — только при lastMove', () => {
const without = draw(threeStones());
expect(without.strokes.filter((s) => s.color === '#ff00ff')).toHaveLength(0);
const withMarker = draw(threeStones(), { lastMove: { x: 4, y: 4 } });
const marker = withMarker.strokes.filter((s) => s.color === '#ff00ff');
expect(marker).toHaveLength(1);
expect(marker[0]?.path.some((op) => op.kind === 'arc')).toBe(true);
});
});
describe('render: разметка из markup', () => {
const markup: NodeMarkup = {
labels: [
{ point: { x: 1, y: 1 }, text: 'A' },
{ point: { x: 2, y: 2 }, text: 'Б' },
],
triangles: [{ x: 3, y: 5 }],
squares: [{ x: 5, y: 3 }],
circles: [{ x: 7, y: 7 }],
};
it('подписи LB присутствуют', () => {
const rec = draw(threeStones(), { markup });
const texts = rec.texts.map((t) => t.text);
expect(texts).toContain('A');
expect(texts).toContain('Б');
});
it('SQ/TR/CR рисуются цветом markupColor по одной фигуре', () => {
const rec = draw(threeStones(), { markup });
const squares = rec.strokeRects.filter((r) => r.color === '#00ff00');
expect(squares).toHaveLength(1);
const shapes = rec.strokes.filter((s) => s.color === '#00ff00');
const lineTos = (s: PaintRecord): number => s.path.filter((op) => op.kind === 'lineTo').length;
expect(shapes.filter((s) => lineTos(s) === 3)).toHaveLength(1); // TR
expect(shapes.filter((s) => s.path.some((op) => op.kind === 'arc'))).toHaveLength(1); // CR
});
});
describe('render: заливка территории', () => {
it('fillRect — только фон и territory-ключи, цвет по владельцу', () => {
const rec = draw(threeStones(), {
territory: { black: new Set(['0,0', '1,0']), white: new Set(['8,8']) },
});
expect(rec.fillRects).toHaveLength(4); // фон + 3 клетки территории
expect(rec.fillRects.filter((r) => r.color === 'rgba(1, 2, 3, 0.5)')).toHaveLength(2);
expect(rec.fillRects.filter((r) => r.color === 'rgba(4, 5, 6, 0.5)')).toHaveLength(1);
});
it('заливка совпадает с координатами ключа "1,0"', () => {
const rec = draw(createBoard(9), { territory: { black: new Set(['1,0']), white: new Set() } });
const cell = 600 / (8 + 2); // size 9, координаты включены
const rect = rec.fillRects.find((r) => r.color === 'rgba(1, 2, 3, 0.5)');
expect(rect?.x).toBeCloseTo(cell + cell - cell / 2, 6);
expect(rect?.y).toBeCloseTo(cell - cell / 2, 6);
expect(rect?.w).toBeCloseTo(cell, 6);
});
it('без territory — единственный fillRect (фон)', () => {
expect(draw(threeStones()).fillRects).toHaveLength(1);
});
});
describe('render: номера ходов', () => {
it('номера рисуются только при showMoveNumbers и только на занятых клетках', () => {
const moveNumbers = new Map([
['2,2', 1],
['6,6', 5],
]);
// Координаты выключены, чтобы их числа не совпадали с номерами ходов.
const off = draw(threeStones(), { moveNumbers, showCoordinates: false });
expect(off.texts.filter((t) => t.text === '1')).toHaveLength(0);
const on = draw(threeStones(), {
showMoveNumbers: true,
moveNumbers,
showCoordinates: false,
});
expect(on.texts.filter((t) => t.text === '1')).toHaveLength(1);
expect(on.texts.filter((t) => t.text === '5')).toHaveLength(0); // клетка пуста
});
});

View file

@ -0,0 +1,333 @@
/**
* Рендер доски на Canvas 2D: чистая функция состояния. Каждый кадр рисуется
* целиком из аргументов (state + options); render ничего не хранит и не
* читает, кроме ctx. CanvasRenderingContext2D только тип параметра.
*
* Порядок слоёв: фон доски заливка территории сетка хоси координаты
* разметка SQ/TR/CR камни метка последнего хода номера ходов фантом
* подписи LB.
*
* Логический размер канвы выводится из ctx: pixelSize = canvas.width / dpr,
* где dpr масштаб текущего трансформа (его выставляет setupCanvas).
*/
import type { BoardState, Color, NodeMarkup, Point } from '@go-learn/core';
import { cellAt, pointKey } from '@go-learn/core';
import type { BoardGeometry } from './geometry.js';
import { computeGeometry, hoshiPoints, pointToPixel } from './geometry.js';
const TAU = Math.PI * 2;
/** Непрозрачность приглушения мёртвых камней (помеченных в options.dead). */
export const DEAD_STONE_ALPHA = 0.45;
export interface BoardTheme {
readonly boardBackground: string;
readonly lineColor: string;
readonly blackStone: string;
readonly whiteStone: string;
readonly blackStoneEdge: string;
readonly whiteStoneEdge: string;
readonly coordinateColor: string;
readonly markupColor: string;
readonly lastMoveMarker: string;
readonly phantomOpacity: number; // 0..1
readonly territoryBlack: string; // заливка с альфой
readonly territoryWhite: string;
}
/** Заливка территории при подсчёте: ключи pointKey ("x,y") по цветам. */
export interface TerritoryMap {
readonly black: ReadonlySet<string>;
readonly white: ReadonlySet<string>;
}
export interface RenderOptions {
readonly theme?: Partial<BoardTheme>; // дефолт — тёмная тема
readonly showCoordinates?: boolean; // дефолт true
readonly showMoveNumbers?: boolean; // дефолт false
readonly moveNumbers?: ReadonlyMap<string, number>; // ключ pointKey ("x,y") → номер хода
readonly lastMove?: Point | null;
readonly phantom?: { readonly point: Point; readonly color: Color } | null;
readonly markup?: NodeMarkup; // из @go-learn/core
readonly territory?: TerritoryMap; // заливка при подсчёте
readonly dead?: ReadonlySet<string>; // ключи pointKey ("x,y"); приглушаются (пониженная непрозрачность)
}
/**
* Дефолтная светлая тема «васи» (этап 10): светлое дерево гобана, коричневая
* сетка, почти чёрный и молочный камни с читаемой обводкой; цвета совпадают
* с токенами сайта (Layout.astro), разметка терракота, последний ход
* индиго. Константы часть дизайна (контракт docs/INTERFACES.md).
*/
const DEFAULT_THEME: BoardTheme = {
boardBackground: '#e6c48a',
lineColor: '#8a6a3f',
blackStone: '#211e1a',
whiteStone: '#f6f2e8',
blackStoneEdge: '#4a453c',
whiteStoneEdge: '#b9ac93',
coordinateColor: '#8a6a3f',
markupColor: '#a84e2c',
lastMoveMarker: '#3d4f7c',
phantomOpacity: 0.5,
territoryBlack: 'rgba(20, 15, 10, 0.45)',
territoryWhite: 'rgba(236, 229, 216, 0.35)',
};
/** Буквы координат без «I» (стандарт Го). */
const LETTERS = 'ABCDEFGHJKLMNOPQRST';
function resolveTheme(partial: Partial<BoardTheme> | undefined): BoardTheme {
return { ...DEFAULT_THEME, ...partial };
}
/** Разбор ключа pointKey ("x,y"); null — формат не совпал. */
function parseKey(key: string): Point | null {
const [xs, ys] = key.split(',');
const x = Number(xs);
const y = Number(ys);
if (!Number.isInteger(x) || !Number.isInteger(y)) return null;
return { x, y };
}
/** Контрастный цвет текста поверх клетки (камня или доски). */
function contrastOn(state: BoardState, point: Point, theme: BoardTheme): string {
const cell = cellAt(state, point);
if (cell === 'black') return theme.whiteStone;
if (cell === 'white') return theme.blackStone;
return theme.markupColor;
}
export function render(
ctx: CanvasRenderingContext2D,
state: BoardState,
options: RenderOptions = {},
): void {
const theme = resolveTheme(options.theme);
const showCoordinates = options.showCoordinates ?? true;
const dpr = ctx.getTransform().a || 1; // масштаб, выставленный setupCanvas
const geo = computeGeometry(state.size, ctx.canvas.width / dpr, showCoordinates);
ctx.save();
drawBackground(ctx, theme, geo.pixelSize);
if (options.territory !== undefined) drawTerritory(ctx, geo, options.territory, theme);
drawGrid(ctx, geo, theme);
drawHoshi(ctx, geo, theme);
if (showCoordinates) drawCoordinates(ctx, geo, theme);
if (options.markup !== undefined) drawMarkupShapes(ctx, geo, options.markup, theme);
drawStones(ctx, state, geo, theme, options.dead);
if (options.lastMove != null) drawLastMove(ctx, geo, options.lastMove, theme);
if ((options.showMoveNumbers ?? false) && options.moveNumbers !== undefined) {
drawMoveNumbers(ctx, state, geo, options.moveNumbers, theme);
}
if (options.phantom != null) drawPhantom(ctx, geo, options.phantom, theme);
if (options.markup !== undefined) drawLabels(ctx, state, geo, options.markup, theme);
ctx.restore();
}
function drawBackground(ctx: CanvasRenderingContext2D, theme: BoardTheme, pixelSize: number): void {
ctx.fillStyle = theme.boardBackground;
ctx.fillRect(0, 0, pixelSize, pixelSize);
}
/** Заливка территории: клетка вокруг пересечения, цвет — по владельцу. */
function drawTerritory(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
territory: TerritoryMap,
theme: BoardTheme,
): void {
const paint = (keys: ReadonlySet<string>, fill: string): void => {
ctx.fillStyle = fill;
for (const key of keys) {
const point = parseKey(key);
if (point === null) continue;
const { x, y } = pointToPixel(geo, point);
ctx.fillRect(x - geo.cell / 2, y - geo.cell / 2, geo.cell, geo.cell);
}
};
paint(territory.black, theme.territoryBlack);
paint(territory.white, theme.territoryWhite);
}
function drawGrid(ctx: CanvasRenderingContext2D, geo: BoardGeometry, theme: BoardTheme): void {
const start = geo.padding;
const end = geo.padding + (geo.size - 1) * geo.cell;
ctx.strokeStyle = theme.lineColor;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < geo.size; i += 1) {
const pos = geo.padding + i * geo.cell;
ctx.moveTo(start, pos);
ctx.lineTo(end, pos);
ctx.moveTo(pos, start);
ctx.lineTo(pos, end);
}
ctx.stroke();
}
function drawHoshi(ctx: CanvasRenderingContext2D, geo: BoardGeometry, theme: BoardTheme): void {
ctx.fillStyle = theme.lineColor;
const radius = Math.max(2, geo.cell * 0.09);
for (const point of hoshiPoints(geo.size)) {
const { x, y } = pointToPixel(geo, point);
ctx.beginPath();
ctx.arc(x, y, radius, 0, TAU);
ctx.fill();
}
}
/** Буквы (без «I») снизу слева направо, числа слева, 1 внизу. */
function drawCoordinates(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
theme: BoardTheme,
): void {
const fontSize = Math.max(9, Math.round(geo.cell * 0.34));
ctx.fillStyle = theme.coordinateColor;
ctx.font = `${fontSize}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const bottom = geo.padding + (geo.size - 1) * geo.cell;
for (let i = 0; i < geo.size; i += 1) {
const pos = geo.padding + i * geo.cell;
ctx.fillText(LETTERS[i] ?? '', pos, bottom + geo.padding * 0.55);
ctx.fillText(String(geo.size - i), geo.padding * 0.45, pos);
}
}
function drawTriangle(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
ctx.beginPath();
ctx.moveTo(x, y - r);
ctx.lineTo(x + r * 0.87, y + r * 0.5);
ctx.lineTo(x - r * 0.87, y + r * 0.5);
ctx.lineTo(x, y - r);
ctx.stroke();
}
/** Разметка SQ/TR/CR — контурными фигурами, под камнями. */
function drawMarkupShapes(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
markup: NodeMarkup,
theme: BoardTheme,
): void {
ctx.strokeStyle = theme.markupColor;
ctx.lineWidth = Math.max(1.5, geo.cell * 0.06);
const r = geo.cell * 0.32;
for (const point of markup.squares) {
const { x, y } = pointToPixel(geo, point);
ctx.strokeRect(x - r, y - r, r * 2, r * 2);
}
for (const point of markup.triangles) {
const { x, y } = pointToPixel(geo, point);
drawTriangle(ctx, x, y, r);
}
for (const point of markup.circles) {
const { x, y } = pointToPixel(geo, point);
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.stroke();
}
}
/** Один камень: круг чуть меньше полклетки, тонкая обводка edge-цветом. */
function drawStone(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
point: Point,
color: Color,
theme: BoardTheme,
alpha: number,
): void {
const { x, y } = pointToPixel(geo, point);
ctx.save();
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(x, y, geo.cell / 2 - 1, 0, TAU);
ctx.fillStyle = color === 'black' ? theme.blackStone : theme.whiteStone;
ctx.fill();
ctx.strokeStyle = color === 'black' ? theme.blackStoneEdge : theme.whiteStoneEdge;
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
function drawStones(
ctx: CanvasRenderingContext2D,
state: BoardState,
geo: BoardGeometry,
theme: BoardTheme,
dead: ReadonlySet<string> | undefined,
): void {
for (let y = 0; y < state.size; y += 1) {
for (let x = 0; x < state.size; x += 1) {
const point = { x, y };
const cell = cellAt(state, point);
if (cell === 'empty') continue;
const alpha = dead?.has(pointKey(point)) === true ? DEAD_STONE_ALPHA : 1;
drawStone(ctx, geo, point, cell, theme, alpha);
}
}
}
/** Метка последнего хода: контрастное кольцо, различимое на обоих цветах. */
function drawLastMove(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
lastMove: Point,
theme: BoardTheme,
): void {
const { x, y } = pointToPixel(geo, lastMove);
ctx.strokeStyle = theme.lastMoveMarker;
ctx.lineWidth = Math.max(2, geo.cell * 0.07);
ctx.beginPath();
ctx.arc(x, y, geo.cell * 0.22, 0, TAU);
ctx.stroke();
}
/** Номера ходов — только на занятых клетках, контрастом к цвету камня. */
function drawMoveNumbers(
ctx: CanvasRenderingContext2D,
state: BoardState,
geo: BoardGeometry,
moveNumbers: ReadonlyMap<string, number>,
theme: BoardTheme,
): void {
ctx.font = `${Math.max(8, Math.round(geo.cell * 0.4))}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const [key, num] of moveNumbers) {
const point = parseKey(key);
if (point === null || cellAt(state, point) === 'empty') continue;
ctx.fillStyle = contrastOn(state, point, theme);
const { x, y } = pointToPixel(geo, point);
ctx.fillText(String(num), x, y);
}
}
function drawPhantom(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
phantom: { readonly point: Point; readonly color: Color },
theme: BoardTheme,
): void {
drawStone(ctx, geo, phantom.point, phantom.color, theme, theme.phantomOpacity);
}
/** Подписи LB — поверх всех слоёв, контрастом к содержимому клетки. */
function drawLabels(
ctx: CanvasRenderingContext2D,
state: BoardState,
geo: BoardGeometry,
markup: NodeMarkup,
theme: BoardTheme,
): void {
ctx.font = `bold ${Math.max(9, Math.round(geo.cell * 0.42))}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const label of markup.labels) {
ctx.fillStyle = contrastOn(state, label.point, theme);
const { x, y } = pointToPixel(geo, label.point);
ctx.fillText(label.text, x, y);
}
}

View file

@ -0,0 +1,59 @@
/**
* Wiring-тест (gotcha-green-modules-dead-system): orphan-check публичного API
* packages/board каждая экспортируемая функция упоминается хотя бы раз вне
* своего определения (в другом модуле пакета или в тесте).
*
* Допустимые «внешние» входные точки: setupCanvas и attachPointerInput
* DOM-адаптер для будущего UI (этап 5), юнит-тестами не покрывается
* (см. plan-phase-2, матрица «чего НЕ покрывает ни один тест»).
*/
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const srcDir = dirname(fileURLToPath(import.meta.url));
/** Входные точки DOM-адаптера: вызываются будущим UI, не тестами. */
const EXTERNAL_ENTRY_POINTS = new Set(['setupCanvas', 'attachPointerInput']);
function sourceFiles(): string[] {
return readdirSync(srcDir)
.filter((name) => name.endsWith('.ts'))
.map((name) => join(srcDir, name));
}
/** Имена, экспортируемые из index.ts (типы пропускаем). */
function exportedNames(): string[] {
const index = readFileSync(join(srcDir, 'index.ts'), 'utf8');
const names: string[] = [];
const pattern = /export\s+(?!type)\{([^}]*)\}\s*from/g;
for (const match of index.matchAll(pattern)) {
const body = match[1] ?? '';
for (const part of body.split(',')) {
const name = part.trim();
if (name !== '') names.push(name);
}
}
return names;
}
describe('orphan-check: публичный API доски связан с кодом', () => {
it('каждый экспорт упоминается вне своего определения', () => {
const files = sourceFiles().filter((file) => !file.endsWith('index.ts'));
const contents = files.map((file) => readFileSync(file, 'utf8'));
const missing: string[] = [];
for (const name of exportedNames()) {
if (EXTERNAL_ENTRY_POINTS.has(name)) continue;
const usage = new RegExp(`\\b${name}\\b`);
const mentions = contents.filter((content) => usage.test(content)).length;
// Определение + минимум одно использование — минимум два файла;
// упоминание в любом *.test.ts тоже засчитывается.
const inTests = contents.some(
(content, index) => files[index]?.endsWith('.test.ts') === true && usage.test(content),
);
if (mentions < 2 && !inTests) missing.push(name);
}
expect(missing).toEqual([]);
});
});

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
// Намеренно БЕЗ references на ../core: гейт typecheck `tsc -b --noEmit`,
// а TS 5.9 в build-режиме с --noEmit отклоняет любую цепочку project
// references с чистого состояния (TS6310 "may not disable emit" CLI-флаг
// распространяется на referenced-проект). Импорт @go-learn/core резолвится
// через workspace-симлинк (main: src/index.ts) типы видны и так.
// Корневой tsconfig.json по-прежнему ссылается на packages/board.
"include": ["src"]
}