feat(core): add what the web redesign needs

ThemeManager takes its list of themes, PitAnimator marks animated pits
with PitViewModel.highlight, getSowingPath lists the pits a move sows
into, and new texts in English and Turkish.
This commit is contained in:
Halit Aksoy 2026-10-16 09:31:28 +03:00
parent 61d169ecdc
commit 8ad833cdbb
8 changed files with 146 additions and 9 deletions

View File

@ -2,7 +2,7 @@
Work through the tasks in order; each ends with its checks passing and a commit. Tick a task when it is committed.
- [ ] 1. **Core**: `ThemeManager` takes its themes; `PitViewModel.highlight` set by `PitAnimator`; `getSowingPath`
- [x] 1. **Core**: `ThemeManager` takes its themes; `PitViewModel.highlight` set by `PitAnimator`; `getSowingPath`
with unit tests; new texts (en, tr).
- [ ] 2. **Backend**: `GET /games/popular?kind=finished`; test.
- [ ] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu,

View File

@ -120,28 +120,34 @@ export class PitAnimator {
if (gameStep.type === GAME_STEP_GAME_MOVE) {
pitViewModel.stoneCount += 1;
pitViewModel.pitColor = theme.pitGameMoveAnimateColor;
pitViewModel.highlight = "sow";
} else if (gameStep.type === GAME_STEP_LAST_STONE_IN_EMPTY_PIT) {
pitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
pitViewModel.highlight = "capture";
pitViewModel.stoneCount = 0;
const oppositeIndex = this.mancalaGame.board.getPitIndexCircularly(
gameStep.data.oppositeIndex
);
const oppositePitViewModel = boardViewModel.pits[oppositeIndex];
oppositePitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
oppositePitViewModel.highlight = "capture";
oppositePitViewModel.stoneCount = 0;
} else if (gameStep.type === GAME_STEP_LAST_STONE_IN_BANK) {
pitViewModel.pitColor = theme.pitLastStoneInBankPitAnimateColor;
pitViewModel.highlight = "store";
} else if (gameStep.type === GAME_STEP_BOARD_CLEARED) {
for (const index of gameStep.data.pitIndexesThatHasStone) {
const oppositeIndex = this.mancalaGame.board.getPitIndexCircularly(index);
const oppositePitViewModel = boardViewModel.pits[oppositeIndex];
oppositePitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
oppositePitViewModel.highlight = "capture";
oppositePitViewModel.stoneCount = 0;
}
} else if (gameStep.type === GAME_STEP_DOUBLE_STONE_IN_PIT) {
const _index = this.mancalaGame.board.getPitIndexCircularly(index);
const pitViewModel = boardViewModel.pits[_index];
pitViewModel.pitColor = theme.pitGetRivalStonePitAnimateColor;
pitViewModel.highlight = "capture";
pitViewModel.stoneCount = 0;
}
pitViewModel.stoneColor = getColorByBrightness(

View File

@ -64,6 +64,16 @@ export function describeMove(moves: PlayedMove[], moveNumber: number): MoveDescr
};
}
// Board indexes the stones of the given pit are dropped into, in order, as Board.move sows them: a single stone goes
// to the next pit; otherwise the first stone goes back into the played pit. Every pit and store is passed, the
// opponent's store too. The last index is where the last stone lands. Empty for an empty pit or a store.
export function getSowingPath(stones: number[], pitIndex: number): number[] {
const stoneCount = stones[pitIndex];
if (!stoneCount || referenceBoard.checkPitTypeIsBankByIndex(pitIndex)) return [];
const start = stoneCount === 1 ? pitIndex + 1 : pitIndex;
return Array.from({ length: stoneCount }, (_, step) => (start + step) % stones.length);
}
// The game as it was after the given move, for showing or animating that position (e.g. with PitAnimator,
// whose last history entry is the move it animates). Only for display: the rebuilt game has no rules.
export function createGameAtMove(game: Game, moves: PlayedMove[], moveNumber: number): Game {

View File

@ -72,4 +72,38 @@ export const en = {
WatchFromStart: "Watch from start",
Speed: "Speed",
Duration: "Duration",
You: "You",
Opponent: "Opponent",
StoreCount: "Store {{count}}",
Online: "Online",
Offline: "Offline",
ChooseTheme: "Choose theme",
ThemeWalnut: "Walnut theme",
ThemeNight: "Night theme",
ThemeJade: "Jade theme",
Account: "Account",
NewestFirst: "newest first",
BeingPlayedNow: "Being played now",
MostWatched: "Most watched",
YourGames: "Your games",
GameWithOpponent: "Your game with {{name}} is on",
StoreScores: "Your store {{mine}}, opponent's {{theirs}}",
Live: "Live",
Close: "Close",
Play: "Play",
Pause: "Pause",
FirstMove: "Go to start",
PreviousMove: "Previous move",
NextMove: "Next move",
LastMove: "Go to end",
MovePosition: "Move position",
PlaybackControls: "Playback controls",
PlaybackSpeed: "Playback speed",
GameSummary: "Game summary",
StartPosition: "4 stones in every pit",
MoveCount: "{{count}} moves",
PitLabel: "{{name}}, pit {{pit}}, {{count}} stones",
StoreLabel: "{{name}}'s store, {{count}} stones",
OpenGame: "Open the game",
Score: "Score",
}

View File

@ -72,4 +72,38 @@ export const tr = {
WatchFromStart: "Baştan izle",
Speed: "Hız",
Duration: "Süre",
You: "Sen",
Opponent: "Rakip",
StoreCount: "Hazine {{count}}",
Online: "Çevrimiçi",
Offline: "Çevrimdışı",
ChooseTheme: "Tema seç",
ThemeWalnut: "Ceviz tema",
ThemeNight: "Gece tema",
ThemeJade: "Yeşim tema",
Account: "Hesap",
NewestFirst: "en yeni üstte",
BeingPlayedNow: "Şu an oynanıyor",
MostWatched: "En çok izlenenler",
YourGames: "Senin maçların",
GameWithOpponent: "{{name}} ile oyunun sürüyor",
StoreScores: "Hazinen {{mine}}, rakibin {{theirs}}",
Live: "Canlı",
Close: "Kapat",
Play: "Oynat",
Pause: "Duraklat",
FirstMove: "Başa dön",
PreviousMove: "Önceki hamle",
NextMove: "Sonraki hamle",
LastMove: "Sona git",
MovePosition: "Hamle konumu",
PlaybackControls: "Oynatma kontrolleri",
PlaybackSpeed: "Oynatma hızı",
GameSummary: "Oyun özeti",
StartPosition: "Her kuyuda 4 taş",
MoveCount: "{{count}} hamle",
PitLabel: "{{name}}, {{pit}}. kuyu, {{count}} taş",
StoreLabel: "{{name}} hazinesi, {{count}} taş",
OpenGame: "Oyunu aç",
Score: "Skor",
};

View File

@ -14,10 +14,13 @@ export type ThemeManagerEvents = "themechange";
export class ThemeManager extends TinyEmitter {
private _theme: Theme;
private _storage: any;
constructor(storage: Storage) {
private _themes: Theme[];
// The first theme is the default.
constructor(storage: Storage, themeList: Theme[] = themes) {
super();
this._storage = storage;
this._theme = this.readFromLocalStorage() || lightTheme;
this._themes = themeList;
this._theme = this.readFromLocalStorage() || themeList[0];
}
public get theme() {
@ -36,12 +39,12 @@ export class ThemeManager extends TinyEmitter {
private readFromLocalStorage(): Theme | undefined {
const themeID = this._storage.getString(THEME_ID);
const theme = themes.find((eachTheme: Theme) => themeID === eachTheme.id);
const theme = this._themes.find((eachTheme: Theme) => themeID === eachTheme.id);
return theme;
}
public get themes(): Theme[] {
return themes;
return this._themes;
}
public on(event: ThemeManagerEvents, callback: (...value: any[]) => void): this {

View File

@ -1,18 +1,25 @@
// Why a pit is marked during a move animation: a stone was sown into it, stones were captured from it, the last stone
// landed in a store, or the player picked it and the move is on its way to the server.
export type PitHighlight = "sow" | "capture" | "store" | "selected";
export class PitViewModel {
id: string;
stoneCount: number;
stoneColor: string;
pitColor: string;
highlight?: PitHighlight;
constructor(
id: string,
stoneCount: number,
stoneColor: string,
pitColor: string
pitColor: string,
highlight?: PitHighlight
) {
this.id = id;
this.stoneCount = stoneCount;
this.stoneColor = stoneColor;
this.pitColor = pitColor;
this.highlight = highlight;
}
}
}

View File

@ -1,5 +1,5 @@
import { CommonMancalaGame, MancalaGame } from "mancala.js";
import { createGameAtMove, describeMove, getBoardAfterMove, getInitialBoard, PlayedMove } from "../../src/history";
import { CommonMancalaGame, GAME_STEP_GAME_MOVE, MancalaGame } from "mancala.js";
import { createGameAtMove, describeMove, getBoardAfterMove, getInitialBoard, getSowingPath, PlayedMove } from "../../src/history";
import { Game } from "../../src/models";
const P1 = "player-1";
@ -136,3 +136,46 @@ describe("createGameAtMove", () => {
expect(start.history).toHaveLength(0);
});
});
describe("getSowingPath", () => {
it("starts with the played pit, or the next pit for a single stone", () => {
const stones = getInitialBoard();
expect(getSowingPath(stones, 2)).toEqual([2, 3, 4, 5]);
stones[4] = 1;
expect(getSowingPath(stones, 4)).toEqual([5]);
});
it("passes both stores and wraps around the board", () => {
const stones = getInitialBoard();
stones[12] = 4;
expect(getSowingPath(stones, 12)).toEqual([12, 13, 0, 1]);
stones[5] = 10;
expect(getSowingPath(stones, 5)).toEqual([5, 6, 7, 8, 9, 10, 11, 12, 13, 0]);
});
it("is empty for an empty pit or a store", () => {
const stones = getInitialBoard();
stones[3] = 0;
expect(getSowingPath(stones, 3)).toEqual([]);
expect(getSowingPath(stones, 6)).toEqual([]);
});
it("matches the pits the engine sows into", () => {
const random = seededRandom(7);
for (let gameNumber = 0; gameNumber < 20; gameNumber++) {
const game = newGame();
while (game.state !== "ended") {
const before = game.board.getStoneArray();
const playable = [0, 1, 2, 3, 4, 5].filter((pit) =>
before[game.getBoardIndexByPlayerId(game.turnPlayerId, pit)] > 0);
const pit = playable[Math.floor(random() * playable.length)];
const boardIndex = game.getBoardIndexByPlayerId(game.turnPlayerId, pit);
game.moveByPlayerPit(game.turnPlayerId, pit);
const sown = game.history[game.history.length - 1].gameSteps
.filter((step) => step.type === GAME_STEP_GAME_MOVE)
.map((step) => game.board.getPitIndexCircularly(step.index));
expect(getSowingPath(before, boardIndex)).toEqual(sown);
}
}
});
});