diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md index 8b15c5f..9f6daa1 100644 --- a/docs/specs/web-redesign/tasks.md +++ b/docs/specs/web-redesign/tasks.md @@ -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, diff --git a/packages/core/src/animation/PitAnimator.ts b/packages/core/src/animation/PitAnimator.ts index 9adea4a..219cb8c 100644 --- a/packages/core/src/animation/PitAnimator.ts +++ b/packages/core/src/animation/PitAnimator.ts @@ -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( diff --git a/packages/core/src/history/MoveHistory.ts b/packages/core/src/history/MoveHistory.ts index e902375..97fd21e 100644 --- a/packages/core/src/history/MoveHistory.ts +++ b/packages/core/src/history/MoveHistory.ts @@ -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 { diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index a550224..58ed73e 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -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", } \ No newline at end of file diff --git a/packages/core/src/localization/tr.ts b/packages/core/src/localization/tr.ts index 23b40a0..153a1b5 100644 --- a/packages/core/src/localization/tr.ts +++ b/packages/core/src/localization/tr.ts @@ -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", }; \ No newline at end of file diff --git a/packages/core/src/theme/ThemeManager.ts b/packages/core/src/theme/ThemeManager.ts index 95ad66b..c78f3ee 100644 --- a/packages/core/src/theme/ThemeManager.ts +++ b/packages/core/src/theme/ThemeManager.ts @@ -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 { diff --git a/packages/core/src/viewmodel/PitViewModel.ts b/packages/core/src/viewmodel/PitViewModel.ts index 7af6e0b..e845b7f 100644 --- a/packages/core/src/viewmodel/PitViewModel.ts +++ b/packages/core/src/viewmodel/PitViewModel.ts @@ -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; } -} +} \ No newline at end of file diff --git a/packages/core/tests/history/MoveHistory.test.ts b/packages/core/tests/history/MoveHistory.test.ts index 6ad7306..aa4bd2d 100644 --- a/packages/core/tests/history/MoveHistory.test.ts +++ b/packages/core/tests/history/MoveHistory.test.ts @@ -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); + } + } + }); +});