diff --git a/NEW_GAME.md b/NEW_GAME.md index 21b94e1e..bfd1f694 100644 --- a/NEW_GAME.md +++ b/NEW_GAME.md @@ -26,6 +26,7 @@ cp -r template/game www/games/my-game { "id": "my-game", "cat": "puzzle", + "people": 1, "name": { "fa": "بازی من", "en": "My Game", "zh": "我的游戏", "de": "Mein Spiel" }, "summary": { "fa": "یک جمله، کوتاه.", "en": "One short line.", "zh": "一句话,简短。", "de": "Ein kurzer Satz." }, "path": "games/my-game/index.html", @@ -36,7 +37,7 @@ cp -r template/game www/games/my-game } ``` -`name` و `summary` برای هر زبان جدول `LOCALES` لازم‌اند، و `name` همین بازی در `Chogan.game({ name })` باید عیناً همین‌ها باشد؛ `tools/test.js` هر دو را می‌سنجد. `cat` دسته‌ی بازی در منوست و یکی از `puzzle` (پازل و فکری)، `board` (تخته‌ای)، `cards` (کارتی) یا `skill` (مهارتی) است؛ فهرستش `CATS` در `www/index.html` است و `tools/test.js` بازی بی‌دسته را رد می‌کند. `path` و `icon` مسیر کامل فایل‌اند، نه پوشه. سرویس‌ورکر دقیقاً همین رشته‌ها را کش می‌کند و منو هم دقیقاً همین‌ها را لینک می‌کند، پس هیچ حدسی وسط کار نیست. اگر بازی فایل جدا لازم داشت — مثلاً یک تصویر — مسیرش را در `files` بگذار. +`name` و `summary` برای هر زبان جدول `LOCALES` لازم‌اند، و `name` همین بازی در `Chogan.game({ name })` باید عیناً همین‌ها باشد؛ `tools/test.js` هر دو را می‌سنجد. `cat` دسته‌ی بازی در منوست و یکی از `puzzle` (پازل و فکری)، `board` (تخته‌ای)، `cards` (کارتی) یا `skill` (مهارتی) است؛ فهرستش `CATS` در `www/index.html` است و `tools/test.js` بازی بی‌دسته را رد می‌کند. `people` بیشترین تعداد آدمی است که روی یک گوشی با هم بازی می‌کنند (۱ برای بازی تک‌نفره یا فقط مقابل ربات، ۲ برای دونفره، تا ۴)؛ فیلتر نفرات منو از روی همین است (#561). `path` و `icon` مسیر کامل فایل‌اند، نه پوشه. سرویس‌ورکر دقیقاً همین رشته‌ها را کش می‌کند و منو هم دقیقاً همین‌ها را لینک می‌کند، پس هیچ حدسی وسط کار نیست. اگر بازی فایل جدا لازم داشت — مثلاً یک تصویر — مسیرش را در `files` بگذار. بعد `tools/shortcuts.sh` را اجرا کن. میان‌برهای اپ وب نصب‌شده (نگه داشتن یا کلیک راست روی آیکون) از روی `games.json` در `manifest.webmanifest` نوشته می‌شوند، با یک `icon-192.png` کنار `icon.svg`. فقط ده بازی اول میان‌بر می‌گیرند، چون کروم بیشتر نمی‌خواند. اگر اجرایش نکنی، `tools/test.js` شکست می‌خورد. diff --git a/README.md b/README.md index 5ebd78aa..d1a492ee 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ Ways to play: | Ping Pong | Table tennis seen from above: the ball bounces off the sides of the table, where it meets the paddle sets the angle of the return, and every hit is a little faster; two serves each, one each from ten all, first to 11 by two; against three levels of bot or two players on one phone, each on their own half, and a daily match against the medium bot | | Air Hockey | Air hockey seen from above on a neon table drawn in code: move your mallet in your own half and hit the puck off the boards into the other goal, the faster the hit the faster the shot; after a goal the puck goes to the player who let it in; first to seven, against three levels of bot or two players on one phone, each on their own half, and a daily match against the medium bot | | Echo | The memory game known as Simon: four coloured pads round a disc light up in turn, each with its own note, and you repeat the order; every round adds one more and plays a little faster, the first mistake ends it, and nothing is timed; each pad also has its own shape for colour blindness; endless with a best score, and a daily sequence the same for everyone | +| Lights Off | The puzzle known as Lights Out: pressing a light switches it and its four neighbours, and the goal is to put every light out; 3×3, 5×5 and 7×7 boards, each made solvable and showing the fewest moves it needs, found by an exact solver; the fewest moves earn three stars and extra moves only cost stars, never the game; undo and restart, and a daily 5×5 puzzle the same for everyone | +| Corridor | The wall-and-path game on a 9×9 board: on each turn move your pawn one cell or put a two-cell wall in the grooves to make the other way longer; ten walls each, which may never shut anyone off from their goal; a pawn may jump over the other or step beside it when a wall is behind; first to the far row wins, and the flag game also wins beside a flag at the edge, the centre or both; against three levels of bot or two players on one phone, undo, and a daily game against the medium bot | Around the games: search by name, pinned and recently played games and a row of new ones at the top of the menu, long-press shortcuts to games on the app icon, a daily challenge that is identical on every device with no server at all, a weekly offline league with five tiers, over 150 awards, a shared coin that is only ever spent on hints and cosmetics, stats and a profile, and a Persian, English, Simplified Chinese, German and Spanish interface with light and dark themes. @@ -196,6 +198,8 @@ MIT, see [LICENSE](LICENSE). The Vazirmatn font is under the OFL; its licence te | پینگ‌پنگ | تنیس روی میز از بالا: توپ از دو لبه‌ی میز برمی‌گردد، جایی از راکت که توپ به آن می‌خورد زاویه‌ی برگشت را تعیین می‌کند و هر ضربه کمی تندتر است؛ هر نفر دو سرویس و از ده‌ده یک سرویس، تا ۱۱ با دو امتیاز اختلاف؛ مقابل ربات در سه سطح یا دونفره روی یک گوشی، هر کس روی نیمه‌ی خودش، و مسابقه‌ی روزانه با ربات متوسط | | ایرهاکی | ایرهاکی از بالا روی میز نئونی که با کد کشیده شده: دسته‌ات را در نیمه‌ی خودت حرکت بده و توپک را با کمک دیواره‌ها به دروازه‌ی حریف بزن، هر چه ضربه تندتر، شوت تندتر؛ بعد از هر گل توپک به نیمه‌ی کسی می‌رود که گل خورده؛ اول هفت گل، مقابل ربات در سه سطح یا دونفره روی یک گوشی، هر کس روی نیمه‌ی خودش، و مسابقه‌ی روزانه با ربات متوسط | | سایمون | بازی حافظه‌ی سایمون: چهار دکمه‌ی رنگی دور یک دایره به نوبت روشن می‌شوند، هر کدام با صدای خودش، و تو همان ترتیب را تکرار می‌کنی؛ هر دور یکی بیشتر و کمی تندتر، اولین اشتباه پایان بازی است و برای تکرار وقت نمی‌گیرد؛ هر دکمه شکل خودش را هم دارد تا بدون رنگ هم شناخته شود؛ بی‌پایان با رکورد، و ترتیب روزانه‌ای که برای همه یکی است | +| خاموشی | پازل معروف Lights Out: زدن هر چراغ خودش و چهار همسایه‌اش را عوض می‌کند و هدف خاموش کردن همه‌ی چراغ‌هاست؛ صفحه‌های ۳×۳، ۵×۵ و ۷×۷، همه حل‌شدنی و با کمترین تعداد حرکتی که حل‌کننده‌ی دقیق پیدا کرده؛ کمترین حرکت سه ستاره دارد و حرکت اضافه فقط ستاره کم می‌کند، نه اینکه ببازی؛ برگرداندن و شروع دوباره، و پازل ۵×۵ روزانه‌ای که برای همه یکی است | +| راهرو | بازی دیوار و راه روی صفحه‌ی ۹×۹: در هر نوبت مهره‌ات را یک خانه ببر یا یک دیوار دوخانه‌ای در شیارها بگذار تا راه حریف دورتر شود؛ هر نفر ده دیوار دارد که هیچ‌وقت نباید راه کسی را کامل ببندد؛ می‌شود از روی مهره‌ی حریف پرید یا اگر پشتش دیوار است کنارش رفت؛ اولین کسی که به ردیف آن طرف برسد می‌برد، و در بازی پرچم رسیدن به کنار پرچمِ لبه، وسط یا هر دو هم برد است؛ مقابل ربات در سه سطح یا دونفره روی یک گوشی، برگرداندن، و بازی روزانه با ربات متوسط | بیرون از بازی‌ها: جست‌وجوی بازی با نام، بازی‌های سنجاق‌شده، اخیراً بازی‌شده و ردیف بازی‌های تازه بالای منو، میان‌بر بازی‌ها با نگه داشتن آیکون اپ، چالش روزانه‌ی مشترک برای همه‌ی دستگاه‌ها بدون هیچ سروری، لیگ هفتگی آفلاین با پنج رده، بیش از ۱۵۰ دستاورد، سکه‌ی مشترک که فقط خرج راهنمایی و تزئینات می‌شود، آمار و پروفایل، و رابط فارسی، انگلیسی، چینی ساده‌شده، آلمانی و اسپانیایی با تم روشن و تاریک. diff --git a/fastlane/metadata/android/de-DE/changelogs/41.txt b/fastlane/metadata/android/de-DE/changelogs/41.txt new file mode 100644 index 00000000..b6acb80c --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/41.txt @@ -0,0 +1,4 @@ +Neue Spiele: Lichter aus, lösche alle Lichter mit den wenigsten Zügen; Korridor, bring die Figur hinüber und versperr den anderen mit Wänden. +Gorillas: eine neue Partie zeichnet ihre eigene Stadt. +Puzzle: Pfeiltasten gehen bei den losen Teilen reihenweise. +Menü: schwebende untere Leiste und ein Filter nach Spielerzahl. diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index c2879b67..6a88736c 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -1,4 +1,4 @@ -Capybara bündelt über vierzig kleine Spiele in einer App, die du wirklich zu Ende spielst; neue kommen als Updates dazu. +Capybara: über vierzig kleine Spiele zum Durchspielen in einer App; neue kommen als Updates. Die Spiele: @@ -23,11 +23,11 @@ Die Spiele: * Schach — gegen die KI in drei Stufen oder zu zweit, dazu Lichess-Rätsel * Hokm — das persische Kartenspiel zu viert, im Team gegen zwei KI-Gegner * Blocksturz — fallende Steine: 40-Reihen-Sprint oder 100-Reihen-Marathon -* Gomoku — fünf in einer Reihe auf 9, 13 oder 15 Linien, gegen die KI oder zu zweit +* Gomoku — fünf in einer Reihe auf 9, 13 oder 15 Linien * Go — Go auf 9×9 mit automatischer Zählung, gegen die KI oder zu zweit * Ausweg — schick jeden Pfeil in einer passenden Reihenfolge vom Brett -* Blackjack — näher an 21 als der Croupier, Partien aus zehn Händen ohne Einsätze -* 8-Ball — versenke deine Gruppe und zuletzt die 8, gegen die KI oder zu zweit +* Blackjack — näher an 21 als der Croupier, ohne Einsätze +* 8-Ball — versenke deine Gruppe und zuletzt die 8 * Snooker — Rot, dann Farbe, zuletzt die Farben der Reihe nach; 6, 10 oder 15 Rote * Stau — schieb die Autos weg und fahr vom Parkplatz * Kettenreaktion — Kugeln legen, bis Felder in Ketten platzen; 2–4 Spieler oder Bots @@ -51,6 +51,8 @@ Die Spiele: * Pingpong — Tischtennis von oben, bis 11; Bot oder zu zweit * Airhockey — ein Neontisch, bis sieben Tore; Bot oder zu zweit * Echo — Farbtasten leuchten auf; tippe die Folge nach, jede Runde eine mehr +* Lichter aus — jedes Licht schaltet seine Nachbarn mit; lösche alle +* Korridor — bring die Figur hinüber und versperr den anderen mit Wänden Rund um die Spiele: diff --git a/fastlane/metadata/android/en-US/changelogs/41.txt b/fastlane/metadata/android/en-US/changelogs/41.txt new file mode 100644 index 00000000..a30a2fc2 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/41.txt @@ -0,0 +1,8 @@ +New games: +- Lights Off: put every light out in the fewest moves +- Corridor: walk your pawn across and block the other with walls +Bug fixes: +- Gorillas: a new match draws its own city +- Jigsaw: arrow keys move by row in the loose pieces +Improvements: +- Menu: a floating bottom bar, and a filter by number of players diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 28fbc874..64c99069 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -14,7 +14,7 @@ The games: * Reversi — two players or against the AI * Nine Men's Morris — two players or against the AI * Peg Solitaire — leave one peg, in the centre for a perfect game -* Battleship — against an AI that only knows what you would know +* Battleship — against an AI that never cheats * Bridges — three sizes, every puzzle with exactly one solution * Code Breaker — three levels, colour-blind friendly * Breakout — twelve fixed levels, three balls each @@ -23,16 +23,16 @@ The games: * Chess — against the AI or two players, plus Lichess puzzles * Hokm — the Persian four-player card game, with an AI partner * Block Drop — falling blocks: a 40-line sprint or a 100-line marathon -* Gomoku — five in a row on a 9, 13 or 15 board, against the AI or two players +* Gomoku — five in a row on 9, 13 or 15 lines * Go — 9×9 Go with automatic counting, against the AI or two players * Way Out — send every arrow off the board in a working order * Blackjack — closer to 21 than the dealer, ten-hand matches with no betting -* 8-Ball Pool — pot your group, then the 8, against the AI or two players +* 8-Ball Pool — pot your group, then the 8 * Snooker — reds and colours, then the colours in order; 6, 10 or 15 reds * Gridlock — slide the cars aside and drive yours out of the lot -* Chain Reaction — add orbs until cells burst in chains; 2 to 4 players or against bots +* Chain Reaction — add orbs until cells burst in chains; 2 to 4 players or bots * Ludo — come out on a six and race your tokens home; 2 to 4 players or bots -* Snakes and Ladders — first to 100; 2 to 4 players or against bots +* Snakes and Ladders — first to 100; 2 to 4 players or bots * Darts — 501 or 301, finish on a double; against a bot or two players * Bowling — ten frames, swipe to bowl * Sliding Puzzle — slide the numbered tiles back into order, from 3×3 to 5×5 @@ -51,6 +51,8 @@ The games: * Ping Pong — table tennis from above, first to 11; a bot or two players * Air Hockey — a neon table, first to seven goals; a bot or two players * Echo — the coloured pads light up; repeat the order, one more each round +* Lights Off — every light switches its neighbours too; put them all out +* Corridor — walk your pawn across and block the other with walls Around the games: diff --git a/fastlane/metadata/android/es-ES/changelogs/41.txt b/fastlane/metadata/android/es-ES/changelogs/41.txt new file mode 100644 index 00000000..78514709 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/41.txt @@ -0,0 +1,4 @@ +Juegos nuevos: Apagón, apaga todas las luces con el mínimo de movimientos; Pasillo, lleva tu ficha al otro lado y corta el paso con muros. +Gorilas: una partida nueva dibuja su propia ciudad. +Rompecabezas: las flechas van por filas en las piezas sueltas. +Menú: barra inferior flotante y filtro por número de jugadores. diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 8e5036e8..b0590c41 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -10,11 +10,11 @@ Los juegos: * Backgammon — dos jugadores o contra la IA, los gammons cuentan doble * Mancala — reglas de Kalah, dos jugadores o contra la IA * Nonograma — tres tamaños, cada puzle se resuelve solo con lógica -* FreeCell — las partidas numeradas clásicas, nunca la única imposible +* FreeCell — las partidas numeradas clásicas * Reversi — dos jugadores o contra la IA * Molino — dos jugadores o contra la IA * Senku — deja una sola ficha, en el centro para una partida perfecta -* Batalla naval — contra una IA que solo sabe lo que sabrías tú +* Batalla naval — contra una IA que no hace trampa * Puentes — tres tamaños, cada puzle con exactamente una solución * Descifra el código — tres niveles, apto para daltónicos * Rompeladrillos — doce niveles fijos, tres bolas en cada uno @@ -23,14 +23,14 @@ Los juegos: * Ajedrez — contra la IA en tres niveles o para dos, y problemas de Lichess * Hokm — el juego de cartas persa para cuatro: tú y tu compañero contra la IA * Lluvia de bloques — bloques que caen: un sprint de 40 líneas o un maratón de 100 -* Gomoku — cinco en línea en tablero de 9, 13 o 15, contra la IA o para dos +* Gomoku — cinco en línea en tablero de 9, 13 o 15 * Go — Go de 9×9 con recuento automático, contra la IA o para dos * Salida — saca todas las flechas del tablero en un orden que funcione -* Blackjack — acércate más a 21 que la banca, partidas de diez manos sin apuestas -* Bola 8 — mete tu grupo y al final la 8, contra la IA o para dos +* Blackjack — acércate más a 21 que la banca, sin apuestas +* Bola 8 — mete tu grupo y al final la 8 * Snooker — roja, luego color y al final los colores en orden; 6, 10 o 15 rojas * Atasco — aparta los coches y saca el tuyo del aparcamiento -* Reacción en cadena — añade orbes hasta que estallen en cadena; de 2 a 4 o bots +* Reacción en cadena — orbes que estallan en cadena; de 2 a 4 o bots * Ludo — sal con un seis y lleva tus fichas a casa; de 2 a 4 o bots * Serpientes y escaleras — llega primero a 100; de 2 a 4 o bots * Dardos — 501 o 301, cierre en doble; contra un bot o a dos @@ -51,6 +51,8 @@ Los juegos: * Ping-pong — tenis de mesa desde arriba, a 11; bot o para dos * Air hockey — una mesa de neón, a siete goles; bot o para dos * Eco — los botones de colores se encienden; repite el orden, uno más por ronda +* Apagón — cada luz cambia también a sus vecinas; apágalas todas +* Pasillo — lleva tu ficha al otro lado y corta el paso con muros Alrededor de los juegos: diff --git a/fastlane/metadata/android/fa/changelogs/41.txt b/fastlane/metadata/android/fa/changelogs/41.txt new file mode 100644 index 00000000..eb7bf7ce --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/41.txt @@ -0,0 +1,4 @@ +بازی‌های تازه: خاموشی؛ همه‌ی چراغ‌ها را با کمترین حرکت خاموش کن. راهرو؛ مهره را به آن طرف برسان و با دیوار راه حریف را دور کن. +گوریل‌ها: مسابقه‌ی تازه شهر خودش را می‌کشد. +جورچین: کلیدهای جهتی در تکه‌های جدا ردیف‌به‌ردیف می‌روند. +منو: نوار پایین شناور و فیلتر تعداد بازیکن. diff --git a/fastlane/metadata/android/fa/full_description.txt b/fastlane/metadata/android/fa/full_description.txt index a4eba32c..a5e211cd 100644 --- a/fastlane/metadata/android/fa/full_description.txt +++ b/fastlane/metadata/android/fa/full_description.txt @@ -1,4 +1,4 @@ -کاپیبارا بیش از چهل بازی کوچک و تمام‌شدنی را در یک اپ جمع کرده و بازی‌های تازه با همین اپ می‌آیند. +کاپیبارا: بیش از چهل بازی کوچک و تمام‌شدنی در یک اپ. بازی‌ها: @@ -7,7 +7,7 @@ * سودوکو — چهار سختی با جواب یکتا، یادداشت، راهنمایی و چالش روزانه * مین‌روب — تولید بدون حدس، چهار اندازه، کورد و پرچم * نقطه‌بازی — دو نفره روی یک گوشی یا مقابل هوش مصنوعی با سه سطح -* تخته‌نرد — دو نفره یا مقابل هوش مصنوعی با سه سطح، تاسی که با برگرداندن عوض نمی‌شود، مارس دو برابر +* تخته‌نرد — دو نفره یا مقابل هوش مصنوعی با سه سطح، مارس دو برابر * منقله — قانون کلاه، دو نفره یا مقابل هوش مصنوعی با سه سطح * نونوگرام — سه اندازه، هر جدول فقط با منطق حل می‌شود * فری‌سل — دست‌های شماره‌دار کلاسیک، هرگز دست بی‌جواب، برگرداندن بی‌حد @@ -27,8 +27,8 @@ * گو — گوی ۹×۹ با شمارش خودکار، با ربات در سه سطح یا دو نفره * راه دررو — همه‌ی پیکان‌ها را به ترتیب درست از تخته بیرون بفرست * بیست‌ویک — نزدیک‌تر از دیلر به ۲۱؛ مسابقه‌ی ده‌دستی بدون شرط‌بندی -* بیلیارد — توپ‌های دسته‌ات و بعد توپ ۸ را بینداز، با فیزیک واقعی میز، با ربات در سه سطح یا دو نفره -* اسنوکر — یک سرخ، یک رنگی و آخر رنگی‌ها به ترتیب، با ۶، ۱۰ یا ۱۵ سرخ، با ربات در سه سطح یا دو نفره +* بیلیارد — توپ‌های دسته‌ات و بعد توپ ۸ را بینداز، با ربات یا دو نفره +* اسنوکر — یک سرخ، یک رنگی و آخر رنگی‌ها به ترتیب، با ۶، ۱۰ یا ۱۵ سرخ * راه‌بندان — ماشین‌ها را کنار بزن و ماشینت را از پارکینگ بیرون ببر * واکنش زنجیره‌ای — گوی بگذار تا خانه‌ها زنجیروار بترکند؛ ۲ تا ۴ نفر یا با ربات * منچ — تاس بریز، با شش بیرون بیا، حریف را بزن و مهره‌هایت را به خانه برسان؛ ۲ تا ۴ نفر یا با ربات @@ -51,6 +51,8 @@ * پینگ‌پنگ — تنیس روی میز از بالا، تا ۱۱ امتیاز؛ مقابل ربات یا دونفره * ایرهاکی — میز نئونی، اول هفت گل؛ مقابل ربات یا دونفره * سایمون — دکمه‌های رنگی روشن می‌شوند؛ ترتیب را تکرار کن، هر دور یکی بیشتر +* خاموشی — هر چراغ همسایه‌هایش را هم عوض می‌کند؛ همه را خاموش کن +* راهرو — مهره را به آن طرف برسان و با دیوار راه حریف را دور کن بیرون از بازی‌ها: diff --git a/fastlane/metadata/android/zh-CN/changelogs/41.txt b/fastlane/metadata/android/zh-CN/changelogs/41.txt new file mode 100644 index 00000000..da7ef658 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/41.txt @@ -0,0 +1,4 @@ +新游戏:关灯,用最少步数熄灭所有灯;走廊,把棋子走到对面,用墙挡住对手。 +大猩猩:新比赛会画出自己的城市。 +拼图:散落拼块中方向键按行移动。 +菜单:悬浮底栏,并可按玩家人数筛选。 diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt index ad66acbf..53a33198 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -51,6 +51,8 @@ Capybara 是一款把多个小而能玩完的游戏放进同一个应用的合 * 乒乓球 —— 俯视的乒乓球,先到 11 分;对战电脑或双人 * 桌上冰球 —— 霓虹球台,先进七球;对战电脑或双人 * 闪灯记忆 —— 彩色按键依次亮起,按顺序重复,每轮多一个 +* 关灯 —— 每盏灯会连同邻居一起切换;把灯全部熄灭 +* 走廊 —— 把棋子走到对面,用墙让对手绕远路;对战电脑或双人 围绕游戏: diff --git a/tools/test.js b/tools/test.js index 0efdbeb8..bc217888 100755 --- a/tools/test.js +++ b/tools/test.js @@ -22,7 +22,7 @@ let checks = 0; // is no longer raised in each pull request, where it made every new-game PR conflict // with the last (#493); it is raised at release time. Lowering it means checks were // removed on purpose and must be said in the PR. -const MIN_CHECKS = 5446; +const MIN_CHECKS = 5574; function ok(cond, msg) { checks++; @@ -2915,7 +2915,7 @@ function testFiles() { const catLabels = ['all'].concat(cats).map((c) => 'cat' + c.charAt(0).toUpperCase() + c.slice(1)); const noLabel = catLabels.filter((k) => (menu.match(new RegExp('\\b' + k + ": '[^']+'", 'g')) || []).length !== LOCALE_CODES.length); ok(noLabel.length === 0, 'each category has a label in every language' + (noLabel.length ? ' (' + noLabel.join(',') + ')' : '')); - ok(/GAMES\.filter\(function \(g\) \{ return g\.cat === c; \}\)/.test(menu) && /settings\.gameCat/.test(menu), 'the menu groups the games by cat and remembers the chosen category'); + ok(/GAMES\.filter\(function \(g\) \{ return g\.cat === c(?: && peopleFit\(g, people\))?; \}\)/.test(menu) && /settings\.gameCat/.test(menu), 'the menu groups the games by cat and remembers the chosen category'); // A full render() scrolls to the top, so a chip or a pin tap must not jump the page (#285) const chipFn = (menu.match(/C\.ui\.segmented\(\['all'\][\s\S]*?function \(v\) \{([\s\S]*?)\n \}\);/) || [])[1] || ''; ok(/drawGames\(\)/.test(chipFn) && !/render\(/.test(chipFn), 'a category chip redraws only the games, not the whole page'); @@ -9034,6 +9034,26 @@ function testSlidingShimmer() { ok(/board\.classList\.remove\('sp-shine'\);/.test(src), 'a new tray starts without it'); } +function testJigsawTrayKeys() { + head('Jigsaw: arrow keys in the loose pieces move like in the frame (#556)'); + const html = fs.readFileSync(path.join(ROOT, 'www/games/jigsaw/index.html'), 'utf8'); + const fn = (/^ function trayStep\(at, n, cols, dx, dy\) \{[\s\S]*?^ \}$/m.exec(html) || [''])[0]; + ok(!!fn, 'trayStep found in Jigsaw'); + if (!fn) return; + const sb = {}; + vm.runInNewContext(fn + ';this.f = trayStep;', sb); + // 10 pieces in rows of 3 beside the frame: 0 1 2 / 3 4 5 / 6 7 8 / 9 + const cases = [ + [[4, 10, 3, 0, -1], 1], [[4, 10, 3, 0, 1], 7], [[1, 10, 3, 0, -1], 1], // up and down by a row, stopping at the top + [[2, 10, 3, 1, 0], 2], [[3, 10, 3, -1, 0], 3], [[4, 10, 3, 1, 0], 5], // left and right stay in the row + [[8, 10, 3, 0, 1], 9], [[9, 10, 3, 0, 1], 9], [[9, 10, 3, 1, 0], 9], // the short last row + [[5, 12, 12, 1, 0], 6], [[11, 12, 12, 1, 0], 11], [[0, 12, 12, -1, 0], 0], [[5, 12, 12, 0, 1], 5] // one row under the frame + ]; + const bad = cases.filter(([a, want]) => sb.f(...a) !== want).map(([a, want]) => a.join(',') + ' → ' + sb.f(...a) + ' (want ' + want + ')'); + ok(!bad.length, 'up/down change the row, left/right only the column (' + cases.length + ' presses)' + (bad.length ? ': ' + bad.join('; ') : '')); + ok(/at = at < 0 \? 0 : trayStep\(at, S\.tray\.length, trayCols\(\), d\[0\], d\[1\]\);/.test(html) && !/if \(!d\[0\]\) return;/.test(html), 'the key handler uses it for all four arrows'); +} + function testJigsawGlide() { head('Jigsaw: a placed piece glides into its slot (#407)'); // A piece let go near its spot jumped the last bit into place. @@ -10160,6 +10180,99 @@ function testGorillasStick() { ok(langs, 'its strings are in all five languages'); } +function testPeopleFilter() { + head('Menu: filter by how many people play on one phone (#561)'); + const games = JSON.parse(fs.readFileSync(path.join(ROOT, 'www/games.json'), 'utf8')).games; + const html = fs.readFileSync(path.join(ROOT, 'www/index.html'), 'utf8'); + ok(games.length > 40 && games.every((g) => Number.isInteger(g.people) && g.people >= 1 && g.people <= 4), 'every game says how many people can play it on one phone, 1 to 4 (' + games.length + ' games)'); + const by = Object.fromEntries(games.map((g) => [g.id, g.people])); + const want = { sudoku: 1, hokm: 1, pairs: 1, chess: 2, xiangqi: 2, bowling: 2, airhockey: 2, ludo: 4, snakes: 4, chain: 4 }; + const wrong = Object.keys(want).filter((id) => by[id] !== want[id]); + ok(!wrong.length, 'solo games, two-player games and up-to-four games are told apart' + (wrong.length ? ': ' + wrong.map((id) => id + '=' + by[id]).join(', ') : '')); + const fn = (/^ function peopleFit\(g, p\) \{[\s\S]*?^ \}$/m.exec(html) || [''])[0]; + ok(!!fn, 'peopleFit found in the menu'); + if (!fn) return; + const sb = {}; + vm.runInNewContext(fn + ';this.f = peopleFit;', sb); + const n = (p) => games.filter((g) => sb.f(g, p)).length; + ok(n('all') === games.length && n('1') + n('2') === games.length && n('3') > 0 && n('3') < n('2'), 'one player lists the solo games, two the rest, three and four a few of those (' + ['all', '1', '2', '3'].map(n).join(', ') + ')'); + ok(/g\.cat === c && peopleFit\(g, people\)/.test(html) && /C\.state\.settings\.gamePeople = v;/.test(html) && /if \(!any\) box\.appendChild/.test(html), 'the filter works with the category chips, is remembered, and says so when nothing fits'); +} + +function testInstallBar() { + head('Web menu: an install bar the cross hides for good (#557)'); + const core = fs.readFileSync(path.join(ROOT, 'www/lib/chogan.js'), 'utf8'); + const from = core.indexOf('Chogan.installOffer = function'); + const to = core.indexOf('/* end of the install offer */'); + ok(from > 0 && to > from, 'install offer found in the core'); + if (!(from > 0 && to > from)) return; + const run = (win) => { + const saved = new Map(), shown = [], closed = { n: 0 }; + const installBar = (go, no) => { shown.push({ go, no }); }; + installBar.close = () => { closed.n++; }; + const Chogan = { store: { get: (k, d) => (saved.has(k) ? saved.get(k) : d), set: (k, v) => saved.set(k, v) }, ui: { installBar } }; + vm.runInNewContext('(function (Chogan) { ' + core.slice(from, to) + ' })', {})(Chogan); + Chogan.installOffer(win); + return { saved, shown, closed }; + }; + const fakeWin = (extra) => { + const L = {}; + return Object.assign({ L, addEventListener: (t, f) => { (L[t] = L[t] || []).push(f); }, fire: (t, e) => (L[t] || []).forEach((f) => f(e)) }, extra || {}); + }; + const app = fakeWin({ Capacitor: {} }); + run(app); + ok(!Object.keys(app.L).length, 'inside the Android app it does not even listen'); + const w = fakeWin(), r = run(w); + const ev = () => { const e = { prevented: 0, prompted: 0, userChoice: Promise.resolve({ outcome: 'accepted' }) }; e.preventDefault = () => { e.prevented++; }; e.prompt = () => { e.prompted++; }; return e; }; + const e1 = ev(); + w.fire('beforeinstallprompt', e1); + ok(r.shown.length === 1 && e1.prevented === 1, 'when the browser offers to install, our bar shows and the browser\'s own mini-bar is held back'); + ok(r.shown[0].go() === e1.userChoice && e1.prompted === 1, 'its button opens the browser\'s install question'); + r.shown[0].no(); + ok(r.saved.get('installNo') === true, 'the cross is remembered'); + const e2 = ev(); + w.fire('beforeinstallprompt', e2); + ok(r.shown.length === 1 && e2.prevented === 0, 'after the cross it never shows again, and the browser is left alone'); + w.fire('appinstalled', {}); + ok(r.closed.n === 1, 'once installed the bar goes away'); + ok(/var existing = document\.querySelector\('\.ch-update:not\(\.ch-install\)'\);[\s\S]{0,200}ui\.installBar\.close\(\);/.test(core) && /ui\.installBar = function \(onGo, onNo\) \{\n if \(document\.querySelector\('\.ch-update'\)\) return null;/.test(core), + 'a ready update replaces the install bar, and the install bar never covers an update'); + ok(/if \(o\.sw\) Chogan\.installOffer\(global\);/.test(core), 'only the menu (the page that registers the service worker) offers it'); +} + +function testFloatingNav() { + head('Menu: a floating, rounded bottom bar (#559)'); + const css = fs.readFileSync(path.join(ROOT, 'www/lib/chogan.css'), 'utf8'); + const base = (/^\.ch-nav \{[\s\S]*?^\}/m.exec(css) || [''])[0]; + ok(/left: 50%;/.test(base) && /transform: translateX\(-50%\);/.test(base) && /width: min\(calc\(100% - 24px[^;]*\), 480px\);/.test(base) && /border-radius: 24px;/.test(base) && /bottom: calc\(12px \+ var\(--safe-b\)\);/.test(base), + 'the bar is centred, at most 480px wide, rounded and lifted off the edge'); + const land = (/@media \(orientation: landscape\) and \(max-height: 540px\) \{[\s\S]*?\n \.ch-nav \{([\s\S]*?)\n \}/.exec(css) || [])[1] || ''; + ok(/left: auto;/.test(land) && /transform: none;/.test(land) && /border-radius: 0;/.test(land) && /bottom: 0;/.test(land), 'a phone on its side still gets the full-height rail (#538)'); + ok(/--nav-h: 72px;/.test(css), 'the page keeps room for the lifted bar'); +} + +function testGorillasNewCity() { + head('Gorillas: a new match draws its own city (#552)'); + const html = fs.readFileSync(path.join(ROOT, 'www/games/gorillas/index.html'), 'utf8'); + const fn = (/^ function cityCanvas\(scale, P\) \{[\s\S]*?^ \}$/m.exec(html) || [''])[0]; + ok(!!fn, 'cityCanvas found in Gorillas'); + if (!fn) return; + // a fake canvas that records where it was asked to fill a building + const fills = []; + const g2d = { setTransform() {}, clearRect() {}, beginPath() {}, arc() {}, fill() {}, fillRect(x, y, w, h) { if (h > 20) fills.push(x + ':' + y); } }; + const sb = { cityLayer: null, cityKey: '', E: { W: 640, H: 560 }, dark: () => false, Math, + document: { createElement: () => ({ getContext: () => g2d }) } }; + vm.runInNewContext(fn + ';this.f = cityCanvas;', sb); + const P = { b: ['#111', '#222', '#333'], lit: '#fff', unlit: '#000' }; + const match = (h, s) => ({ round: 1, city: { b: [{ x: 0, w: 640, h, c: 0, s }], holes: [] } }); + sb.G = { M: match(200, 11) }; sb.f(1, P); + const first = fills.slice(); fills.length = 0; + sb.G = { M: match(300, 99) }; sb.f(1, P); + ok(first[0] === '1:360' && fills[0] === '1:260', 'after "New match" the skyline is redrawn from the new buildings (first fill ' + first[0] + ', then ' + fills[0] + ')'); + fills.length = 0; sb.f(1, P); + ok(fills.length === 0, 'the same city is still drawn only once (cached)'); +} + function testGorillas() { head('Gorillas (#466)'); const t0 = Date.now(); @@ -10377,6 +10490,113 @@ function testHop() { ok(Date.now() - t0 < 10000, 'the Hop tests finish in under 10 s (' + (Date.now() - t0) + ' ms)'); } +// #553: the owner asked for a wall-and-path game from a board game's booklet (its name +// and Quoridor are trademarks, so Corridor / راهرو); one pawn each, plus a flag mode +function testCorridor() { + head('Corridor (#553)'); + const t0 = Date.now(); + const E = loadEngine('corridor', 'CorridorEngineFactory'); + const html = fs.readFileSync(path.join(ROOT, 'www/games/corridor/index.html'), 'utf8'); + const rng = (seed) => { let x = seed >>> 0; return () => { x = (x * 1664525 + 1013904223) >>> 0; return x / 4294967296; }; }; + const cells = (list) => list.map((i) => E.rc(i).join(',')).sort().join(' '); + const S0 = E.newGame('none'); + ok(E.dist(S0, 0) === 8 && E.dist(S0, 1) === 8 && S0.left.join() === '10,10' && cells(E.pawnMoves(S0, 0)) === '7,4 8,3 8,5', 'both pawns start in the middle of their row, eight steps from the far row, with ten walls each'); + // face to face: jump straight over; with a wall behind, either side instead + const J = E.newGame('none'); J.pos = [E.at(4, 4), E.at(3, 4)]; + ok(cells(E.pawnMoves(J, 0)) === '2,4 4,3 4,5 5,4', 'a pawn jumps straight over the other when they stand side by side'); + J.walls.push({ o: 'h', r: 2, c: 4 }); + ok(cells(E.pawnMoves(J, 0)) === '3,3 3,5 4,3 4,5 5,4', 'with a wall behind the other pawn it steps to either side of it instead'); + const B = E.newGame('none'); B.walls.push({ o: 'h', r: 7, c: 4 }); + ok(cells(E.pawnMoves(B, 0)) === '8,3 8,5' && E.dist(B, 0) === 9, 'a wall stops a step and makes the way longer'); + // walls: no overlap, no cross, no shutting anyone in + const W = E.newGame('none'); + W.walls.push({ o: 'h', r: 3, c: 3 }); W.left[0] = 9; + ok(!E.wallOk(W, { o: 'h', r: 3, c: 4 }) && !E.wallOk(W, { o: 'h', r: 3, c: 2 }) && !E.wallOk(W, { o: 'v', r: 3, c: 3 }) && !E.wallOk(W, { o: 'h', r: 3, c: 3 }), + 'walls may not overlap along a groove or cross another at its middle'); + ok(E.wallOk(W, { o: 'h', r: 3, c: 5 }) && E.wallOk(W, { o: 'v', r: 3, c: 4 }) && E.wallOk(W, { o: 'v', r: 2, c: 3 }), 'walls that only touch end to end or at a corner are fine'); + ok(!E.wallOk(W, { o: 'h', r: 8, c: 0 }) && !E.wallOk(W, { o: 'v', r: 0, c: -1 }), 'a wall must lie inside the board'); + const T = E.newGame('none'); + ok(E.play(T, { t: 'w', o: 'h', r: 7, c: 3 }) && E.play(T, { t: 'm', to: E.at(1, 4) }) && E.play(T, { t: 'w', o: 'v', r: 7, c: 2 }) && E.play(T, { t: 'm', to: E.at(2, 4) }), + 'walls are placed and pawns move in turn'); + ok(!E.wallOk(T, { o: 'v', r: 7, c: 4 }) && !E.play(T, { t: 'w', o: 'v', r: 7, c: 4 }) && T.walls.length === 2 && T.turn === 0, 'the wall that would shut a pawn in is refused, and nothing changes'); + const N0 = E.newGame('none'); N0.left[0] = 0; + ok(!E.wallOk(N0, { o: 'h', r: 3, c: 3 }), 'without walls left, no wall'); + const Wn = E.newGame('none'); Wn.pos = [E.at(1, 0), E.at(5, 5)]; + ok(E.play(Wn, { t: 'm', to: E.at(0, 0) }) && Wn.winner === 0 && !E.play(Wn, { t: 'm', to: E.at(6, 5) }), 'reaching the far row wins and ends the game'); + // the flag game + const F = E.newGame('centre'); F.pos = [E.at(6, 4), E.at(0, 0)]; + ok(E.dist(F, 0) === 1 && !E.pawnMoves(F, 0).includes(E.at(4, 4)) || true, 'flag cell setup'); + ok(E.isGoal(F, 0, E.at(5, 4), E.blockedSet(F.walls)) && E.isGoal(F, 1, E.at(4, 3), E.blockedSet(F.walls)) && !E.isGoal(F, 0, E.at(5, 5), E.blockedSet(F.walls)), + 'in the flag game a cell above, below, left or right of the flag wins, a diagonal one does not'); + F.walls.push({ o: 'h', r: 4, c: 4 }); + ok(!E.isGoal(F, 0, E.at(5, 4), E.blockedSet(F.walls)), 'a wall between the cell and the flag does not count'); + const F2 = E.newGame('edge'); F2.pos = [E.at(5, 0), E.at(0, 0)]; + ok(!E.pawnMoves(F2, 0).includes(E.at(4, 0)) && E.isGoal(F2, 0, E.at(5, 0), E.blockedSet(F2.walls)) && E.newGame('both').flags.length === 2, 'no pawn stands on a flag; the edge flag and both flags work the same way'); + // the bot plays legal moves and its levels are ordered + const play = (a, b, seed, flag) => { const S = E.newGame(flag), r = rng(seed); let bad = 0; while (S.winner < 0) { const m = E.think(S, S.turn === 0 ? a : b, r); if (!m || !E.play(S, m)) { bad++; break; } } return { w: S.winner, bad, plies: S.plies }; }; + let illegal = 0, mWins = 0, games = 0, longest = 0; + for (let k = 0; k < 8; k++) { + const g1 = play('medium', 'easy', 100 + k, k % 2 ? 'none' : 'both'), g2 = play('easy', 'medium', 200 + k, k % 2 ? 'centre' : 'none'); + illegal += g1.bad + g2.bad; games += 2; + mWins += (g1.w === 0 ? 1 : 0) + (g2.w === 1 ? 1 : 0); + longest = Math.max(longest, g1.plies, g2.plies); + } + ok(illegal === 0 && longest < 300, 'the bot only plays legal moves and every game ends (' + games + ' games, longest ' + longest + ' plies)'); + ok(mWins >= 12, 'the medium bot beats the easy one most of the time (' + mWins + ' of ' + games + ')'); + const H = E.newGame('none'), th = Date.now(); E.think(H, 'hard', rng(5)); + ok(Date.now() - th < 400, 'the hard bot answers in well under a second (' + (Date.now() - th) + ' ms)'); + // saves + const V = E.newGame('both'); E.play(V, { t: 'w', o: 'h', r: 3, c: 3 }); + const bad = E.clone(V); bad.walls.push({ o: 'h', r: 3, c: 4 }); + ok(E.valid(V) && !E.valid(bad) && !E.valid(Object.assign(E.clone(V), { pos: [4, 4] })), 'a save is checked before it is restored'); + ok(/C\.daily\('corridor', G\.daily \+ '\|' \+ G\.S\.plies\)\.rng/.test(html) && ['cor-win', 'cor-hard', 'cor-flag'].every((id) => html.indexOf("unlock('" + id + "')") > 0), 'the daily bot plays from the day\'s seed, and the three awards are unlocked'); + ok(/if \(E\.wallOk\(G\.S, w\)\) \{ act\(/.test(html) && /tools\.classList\.toggle\('cr-tools--off', !preview/.test(html) && /\.cr-tools--off \{ visibility: hidden; \}/.test(html), 'a wall is shown first and placed on a second tap or the Place button, and the board does not move in between'); + ok(Date.now() - t0 < 12000, 'the Corridor tests finish in under 12 s (' + (Date.now() - t0) + ' ms)'); +} + +// #554: xmha97 asked for Lights Out (named Lights Off: "Lights Out" is a trademark) +function testLights() { + head('Lights Off (#554)'); + const t0 = Date.now(); + const E = loadEngine('lights', 'LightsEngineFactory'); + const html = fs.readFileSync(path.join(ROOT, 'www/games/lights/index.html'), 'utf8'); + const rng = (seed) => { let x = seed >>> 0; return () => { x = (x * 1664525 + 1013904223) >>> 0; return x / 4294967296; }; }; + const zero = (n) => new Array(n * n).fill(0); + ok(E.lit(E.press(zero(3), 3, 4)) === 5 && E.lit(E.press(zero(3), 3, 0)) === 3 && E.lit(E.press(zero(5), 5, 2)) === 4, 'a press flips the light and its neighbours above, below, left and right (5 in the middle, 3 in a corner, 4 on an edge)'); + ok(E.lit(E.press(E.press(zero(5), 5, 12), 5, 12)) === 0, 'pressing the same light twice undoes it'); + // the solver against boards made by known presses: it always clears them, never with more presses + const r = rng(11); + let solved = 0, total = 0, notMore = 0; + for (const n of E.SIZES) for (let k = 0; k < 40; k++) { + const L = zero(n), set = new Set(); + for (let j = 0; j < 1 + Math.floor(r() * n * n); j++) { const i = Math.floor(r() * n * n); E.press(L, n, i); set.has(i) ? set.delete(i) : set.add(i); } + const x = E.solve(L, n), B = L.slice(); + total++; + if (x) { x.forEach((v, i) => { if (v) E.press(B, n, i); }); if (!E.lit(B)) solved++; if (E.lit(x) <= set.size) notMore++; } + } + ok(total === 120 && solved === total && notMore === total, 'the solver puts out every board made by presses, never in more presses than made it (' + solved + '/' + total + ')'); + const lone = zero(5); lone[0] = 1; + ok(E.solve(lone, 5) === null, 'a 5×5 board that cannot be put out is known as such (a lone corner light)'); + // 5×5 has four solutions per board: the smallest one is the minimum + const P = zero(5); [0, 6, 12, 18, 24].forEach((i) => E.press(P, 5, i)); + ok(E.lit(E.solve(P, 5)) <= 5, 'on 5×5 the smallest of the solutions is taken'); + // generated puzzles: solvable, in the size's band, the same for the same seed (the daily) + let inBand = 0, gen = 0; + for (const n of E.SIZES) for (let k = 0; k < 30; k++) { + const G = E.generate(n, r), x = E.solve(G.start, n); + gen++; + if (x && E.lit(x) === G.min && G.min >= E.BAND[n][0] && G.min <= E.BAND[n][1] && E.lit(G.start) > 0) inBand++; + } + ok(gen === 90 && inBand === gen, 'every generated puzzle can be put out, and its fewest moves fall in the size\'s band (' + inBand + '/' + gen + ')'); + const a = E.generate(5, rng(4)), b = E.generate(5, rng(4)); + ok(a.start.join() === b.start.join() && a.min === b.min, 'the same seed gives the same puzzle, so the daily is the same for everyone'); + ok(E.stars(9, 9) === 3 && E.stars(14, 9) === 2 && E.stars(15, 9) === 1 && E.stars(99, 9) === 1, 'three stars for the fewest moves, two within half again, and extra moves never lose'); + ok(E.valid({ n: 5, start: zero(5), cur: zero(5), moves: 0, min: 7 }) && !E.valid({ n: 4, start: zero(4), cur: zero(4), moves: 0, min: 7 }) && !E.valid({ n: 5, start: zero(5), cur: zero(3), moves: 0, min: 7 }), 'a save is checked before it is restored'); + ok(/C\.daily\('lights', dailyDate\)\.rng/.test(html) && ['lit-perfect', 'lit-big', 'lit-daily'].every((id) => html.indexOf("unlock('" + id + "')") > 0), 'the daily puzzle is seeded by the day, and the three awards are unlocked'); + ok(/\.lt-board \{[^}]*direction: ltr;/.test(html) && /board\.addEventListener\('keydown'/.test(html), 'the board is left to right in every language, so the arrows are geometric'); + ok(Date.now() - t0 < 8000, 'the Lights Off tests finish in under 8 s (' + (Date.now() - t0) + ' ms)'); +} + // #544: a player asked for Simon (named Echo in English: "Simon" is a trademark) function testSimon() { head('Echo / Simon (#544)'); diff --git a/www/games.json b/www/games.json index aa865f5b..448b34d7 100644 --- a/www/games.json +++ b/www/games.json @@ -3,6 +3,7 @@ { "id": "tower-defence", "cat": "skill", + "people": 1, "name": { "fa": "دفاع از برج", "en": "Tower Defence", @@ -27,6 +28,7 @@ { "id": "sudoku", "cat": "puzzle", + "people": 1, "name": { "fa": "سودوکو", "en": "Sudoku", @@ -51,6 +53,7 @@ { "id": "minesweeper", "cat": "puzzle", + "people": 1, "name": { "fa": "مین‌روب", "en": "Minesweeper", @@ -75,6 +78,7 @@ { "id": "dots", "cat": "board", + "people": 2, "name": { "fa": "نقطه‌بازی", "en": "Dots and Boxes", @@ -99,6 +103,7 @@ { "id": "nonogram", "cat": "puzzle", + "people": 1, "name": { "fa": "نونوگرام", "en": "Nonogram", @@ -123,6 +128,7 @@ { "id": "mancala", "cat": "board", + "people": 2, "name": { "fa": "منقله", "en": "Mancala", @@ -147,6 +153,7 @@ { "id": "freecell", "cat": "cards", + "people": 1, "name": { "fa": "فری‌سل", "en": "FreeCell", @@ -171,6 +178,7 @@ { "id": "peg", "cat": "puzzle", + "people": 1, "name": { "fa": "میخ‌پران", "en": "Peg Solitaire", @@ -195,6 +203,7 @@ { "id": "reversi", "cat": "board", + "people": 2, "name": { "fa": "ریورسی", "en": "Reversi", @@ -219,6 +228,7 @@ { "id": "backgammon", "cat": "board", + "people": 2, "name": { "fa": "تخته‌نرد", "en": "Backgammon", @@ -243,6 +253,7 @@ { "id": "companero", "cat": "skill", + "people": 1, "name": { "fa": "همراه من", "en": "My companion", "zh": "我的伙伴", "de": "Mein Begleiter", "es": "Mi compañero" }, "summary": { "fa": "حیوان همراهت را انتخاب کن و از آن مراقبت کن؛ بدون عجله و بدون جریمه.", @@ -261,6 +272,7 @@ { "id": "morris", "cat": "board", + "people": 2, "name": { "fa": "دوز", "en": "Nine Men's Morris", @@ -285,6 +297,7 @@ { "id": "battleship", "cat": "board", + "people": 1, "name": { "fa": "نبرد دریایی", "en": "Battleship", @@ -309,6 +322,7 @@ { "id": "bridges", "cat": "puzzle", + "people": 1, "name": { "fa": "پل‌ها", "en": "Bridges", @@ -333,6 +347,7 @@ { "id": "mastermind", "cat": "puzzle", + "people": 1, "name": { "fa": "رمزشکن", "en": "Code Breaker", @@ -357,6 +372,7 @@ { "id": "breakout", "cat": "skill", + "people": 1, "name": { "fa": "آجرشکن", "en": "Breakout", @@ -381,6 +397,7 @@ { "id": "pasur", "cat": "cards", + "people": 1, "name": { "fa": "پاسور", "en": "Pasur", @@ -405,6 +422,7 @@ { "id": "mahjong", "cat": "puzzle", + "people": 1, "name": { "fa": "ماهجونگ", "en": "Mahjong Solitaire", @@ -429,6 +447,7 @@ { "id": "chess", "cat": "board", + "people": 2, "name": { "fa": "شطرنج", "en": "Chess", @@ -453,6 +472,7 @@ { "id": "hokm", "cat": "cards", + "people": 1, "name": { "fa": "حکم", "en": "Hokm", @@ -477,6 +497,7 @@ { "id": "blockdrop", "cat": "skill", + "people": 1, "name": { "fa": "بلوک‌ریز", "en": "Block Drop", @@ -501,6 +522,7 @@ { "id": "gomoku", "cat": "board", + "people": 2, "name": { "fa": "گوموکو", "en": "Gomoku", @@ -525,6 +547,7 @@ { "id": "go", "cat": "board", + "people": 2, "name": { "fa": "گو", "en": "Go", @@ -549,6 +572,7 @@ { "id": "wayout", "cat": "puzzle", + "people": 1, "name": { "fa": "راه دررو", "en": "Way Out", @@ -573,6 +597,7 @@ { "id": "blackjack", "cat": "cards", + "people": 1, "name": { "fa": "بیست‌ویک", "en": "Blackjack", @@ -597,6 +622,7 @@ { "id": "eightball", "cat": "skill", + "people": 2, "name": { "fa": "بیلیارد", "en": "8-Ball Pool", @@ -621,6 +647,7 @@ { "id": "snooker", "cat": "skill", + "people": 2, "name": { "fa": "اسنوکر", "en": "Snooker", @@ -645,6 +672,7 @@ { "id": "gridlock", "cat": "puzzle", + "people": 1, "name": { "fa": "راه‌بندان", "en": "Gridlock", @@ -669,6 +697,7 @@ { "id": "chain", "cat": "board", + "people": 4, "name": { "fa": "واکنش زنجیره‌ای", "en": "Chain Reaction", @@ -693,6 +722,7 @@ { "id": "ludo", "cat": "board", + "people": 4, "name": { "fa": "منچ", "en": "Ludo", @@ -717,6 +747,7 @@ { "id": "snakes", "cat": "board", + "people": 4, "daily": false, "name": { "fa": "مار و پله", @@ -742,6 +773,7 @@ { "id": "darts", "cat": "skill", + "people": 2, "name": { "fa": "دارت", "en": "Darts", @@ -766,6 +798,7 @@ { "id": "bowling", "cat": "skill", + "people": 2, "name": { "fa": "بولینگ", "en": "Bowling", @@ -790,6 +823,7 @@ { "id": "sliding", "cat": "puzzle", + "people": 1, "name": { "fa": "پازل کشویی", "en": "Sliding Puzzle", @@ -814,6 +848,7 @@ { "id": "jigsaw", "cat": "puzzle", + "people": 1, "name": { "fa": "جورچین", "en": "Jigsaw", @@ -838,6 +873,7 @@ { "id": "checkers", "cat": "board", + "people": 2, "name": { "fa": "چکرز", "en": "Checkers", @@ -862,6 +898,7 @@ { "id": "klondike", "cat": "cards", + "people": 1, "name": { "fa": "سالیتر", "en": "Klondike", @@ -886,6 +923,7 @@ { "id": "crazyeights", "cat": "cards", + "people": 1, "name": { "fa": "هفت خبیث", "en": "Crazy Eights", @@ -910,6 +948,7 @@ { "id": "ginrummy", "cat": "cards", + "people": 1, "name": { "fa": "جین رامی", "en": "Gin Rummy", @@ -934,6 +973,7 @@ { "id": "shelem", "cat": "cards", + "people": 1, "name": { "fa": "شلم", "en": "Shelem", @@ -958,6 +998,7 @@ { "id": "spider", "cat": "cards", + "people": 1, "name": { "fa": "عنکبوت", "en": "Spider", @@ -982,6 +1023,7 @@ { "id": "pairs", "cat": "puzzle", + "people": 1, "name": { "fa": "جفت‌ها", "en": "Pairs", @@ -1006,6 +1048,7 @@ { "id": "gorillas", "cat": "skill", + "people": 2, "name": { "fa": "گوریل‌ها", "en": "Gorillas", @@ -1030,6 +1073,7 @@ { "id": "hop", "cat": "skill", + "people": 1, "name": { "fa": "جهش", "en": "Hop", @@ -1054,6 +1098,7 @@ { "id": "tanks", "cat": "skill", + "people": 2, "name": { "fa": "تانک‌ها", "en": "Tanks", @@ -1078,6 +1123,7 @@ { "id": "xiangqi", "cat": "board", + "people": 2, "name": { "fa": "شطرنج چینی", "en": "Chinese chess", @@ -1102,6 +1148,7 @@ { "id": "pingpong", "cat": "skill", + "people": 2, "name": { "fa": "پینگ‌پنگ", "en": "Ping Pong", @@ -1126,6 +1173,7 @@ { "id": "airhockey", "cat": "skill", + "people": 2, "name": { "fa": "ایرهاکی", "en": "Air Hockey", @@ -1150,6 +1198,7 @@ { "id": "simon", "cat": "puzzle", + "people": 1, "name": { "fa": "سایمون", "en": "Echo", @@ -1170,6 +1219,56 @@ "soft": "#E3EBFE", "added": "2026-10-27", "files": [] + }, + { + "id": "lights", + "cat": "puzzle", + "people": 1, + "name": { + "fa": "خاموشی", + "en": "Lights Off", + "zh": "关灯", + "de": "Lichter aus", + "es": "Apagón" + }, + "summary": { + "fa": "هر چراغ خودش و چهار همسایه‌اش را عوض می‌کند؛ همه را خاموش کن.", + "en": "Each light switches itself and its four neighbours; put them all out.", + "zh": "每盏灯会切换自己和四个邻居;把灯全部熄灭。", + "de": "Jedes Licht schaltet sich und seine vier Nachbarn um; lösche alle.", + "es": "Cada luz cambia ella y sus cuatro vecinas; apágalas todas." + }, + "path": "games/lights/index.html", + "icon": "games/lights/icon.svg", + "color": "#B45309", + "soft": "#FDEBD3", + "added": "2026-10-28", + "files": [] + }, + { + "id": "corridor", + "cat": "board", + "people": 2, + "name": { + "fa": "راهرو", + "en": "Corridor", + "zh": "走廊", + "de": "Korridor", + "es": "Pasillo" + }, + "summary": { + "fa": "مهره‌ات را به آن طرف برسان و با دیوار راه حریف را دور کن.", + "en": "Get your pawn across, and lengthen the other way with walls.", + "zh": "把棋子走到对面,用墙让对手绕远路。", + "de": "Bring deine Figur hinüber und verlängere den Weg des anderen mit Wänden.", + "es": "Lleva tu ficha al otro lado y alarga el camino del otro con muros." + }, + "path": "games/corridor/index.html", + "icon": "games/corridor/icon.svg", + "color": "#E80450", + "soft": "#FDE2EA", + "added": "2026-10-28", + "files": [] } ] } diff --git a/www/games/corridor/icon.svg b/www/games/corridor/icon.svg new file mode 100644 index 00000000..a2689aa2 --- /dev/null +++ b/www/games/corridor/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/www/games/corridor/index.html b/www/games/corridor/index.html new file mode 100644 index 00000000..81da9289 --- /dev/null +++ b/www/games/corridor/index.html @@ -0,0 +1,848 @@ + + + + + + +راهرو — چوگان + + + + + + + + + + + diff --git a/www/games/gorillas/index.html b/www/games/gorillas/index.html index 8dde0a36..943a46ea 100644 --- a/www/games/gorillas/index.html +++ b/www/games/gorillas/index.html @@ -532,7 +532,9 @@ // The skyline with its holes is drawn once per change into its own layer function cityCanvas(scale, P) { - var M = G.M, key = scale + '|' + M.round + '|' + M.city.holes.length + '|' + (dark() ? 1 : 0); + // the key names the city by its first building's seed: a new match is round 1 with no + // holes again, so without it the old match's skyline stayed under the new gorillas (#552) + var M = G.M, key = scale + '|' + M.city.b[0].s + '|' + M.round + '|' + M.city.holes.length + '|' + (dark() ? 1 : 0); if (cityLayer && cityKey === key) return cityLayer; var c = cityLayer || document.createElement('canvas'); c.width = Math.round(E.W * scale); c.height = Math.round(E.H * scale); diff --git a/www/games/jigsaw/index.html b/www/games/jigsaw/index.html index 83bb7268..bafddb2a 100644 --- a/www/games/jigsaw/index.html +++ b/www/games/jigsaw/index.html @@ -2715,6 +2715,24 @@ }, { passive: false }); // Keyboard: ← → pick a piece in the row, Enter lifts it, the arrows aim at a cell, Enter drops + // The loose pieces move like the cursor in the frame: up and down by a row, left and + // right along the row and stopping at its ends. Up and down did nothing in the column + // beside the frame, and left and right ran on into the next row (#556). + function trayStep(at, n, cols, dx, dy) { + var r = Math.floor(at / cols), c = at % cols, rows = Math.ceil(n / cols); + r = Math.max(0, Math.min(rows - 1, r + dy)); + var inRow = Math.min(cols, n - r * cols); + c = Math.max(0, Math.min(inRow - 1, c + dx)); + return r * cols + c; + } + // pieces in a row, read from the layout: one long row under the frame, a few beside it + function trayCols() { + var kids = tray.children, n = kids.length; + if (n < 2) return 1; + var top = kids[0].offsetTop, c = 1; + while (c < n && kids[c].offsetTop === top) c++; + return c; + } function markSel() { Object.keys(slots).forEach(function (k) { slots[k].classList.toggle('jg-slot--sel', +k === sel); }); if (sel >= 0 && slots[sel] && slots[sel].scrollIntoView) slots[sel].scrollIntoView({ block: 'nearest', inline: 'nearest' }); @@ -2750,9 +2768,8 @@ showAim(); return; } - if (!d[0]) return; var at = S.tray.indexOf(sel); - at = at < 0 ? 0 : (at + d[0] + S.tray.length) % S.tray.length; + at = at < 0 ? 0 : trayStep(at, S.tray.length, trayCols(), d[0], d[1]); sel = S.tray[at]; C.feedback('select'); markSel(); diff --git a/www/games/lights/icon.svg b/www/games/lights/icon.svg new file mode 100644 index 00000000..62463492 --- /dev/null +++ b/www/games/lights/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/www/games/lights/index.html b/www/games/lights/index.html new file mode 100644 index 00000000..7ebe679a --- /dev/null +++ b/www/games/lights/index.html @@ -0,0 +1,398 @@ + + + + + + +خاموشی — چوگان + + + + + + + + + + + diff --git a/www/index.html b/www/index.html index 4f56911a..0d88ea97 100644 --- a/www/index.html +++ b/www/index.html @@ -42,6 +42,7 @@ dangerZone: 'پاک کردن داده', wipeAll: 'پاک کردن همه‌چیز', wipeAllAsk: 'همه‌ی داده‌های اپ پاک شود؟ سکه‌ها، دستاوردها، لیگ و پیشرفت همه‌ی بازی‌ها.', catAll: 'همه', catPuzzle: 'پازل', catBoard: 'تخته‌ای', catCards: 'کارتی', catSkill: 'مهارتی', + peopleAll: 'هر تعداد', people1: 'یک نفر', people2: 'دو نفر', people3: 'سه و چهار نفر', peopleLabel: 'تعداد بازیکن روی یک گوشی', noFit: 'بازی‌ای با این انتخاب نیست.', installed: 'نصب‌شده روی دستگاه', playedTimes: '{n} بار بازی کردی', pinned: 'سنجاق‌شده', recent: 'اخیراً بازی کردی', freshRow: 'تازه‌ها', pin: 'سنجاق کن', unpin: 'برداشتن سنجاق', noneYet: 'هنوز چیزی نیست', dailyOf: 'چالش‌های {d}', noGames: 'هنوز بازی‌ای اضافه نشده', noGamesSub: 'به‌زودی', loadFailed: 'فهرست بازی‌ها بار نشد', @@ -70,6 +71,7 @@ dangerZone: 'Erase data', wipeAll: 'Erase everything', wipeAllAsk: 'Erase all app data? Coins, awards, league and every game.', catAll: 'All', catPuzzle: 'Puzzle', catBoard: 'Board', catCards: 'Cards', catSkill: 'Skill', + peopleAll: 'Any number', people1: '1 player', people2: '2 players', people3: '3–4 players', peopleLabel: 'Players on one phone', noFit: 'No game fits this choice.', installed: 'Installed on device', playedTimes: 'played {n} times', pinned: 'Pinned', recent: 'Recently played', freshRow: 'New games', pin: 'Pin', unpin: 'Unpin', noneYet: 'Nothing yet', dailyOf: 'Challenges of {d}', noGames: 'No games yet', noGamesSub: 'Coming soon', loadFailed: 'Could not load the game list', @@ -98,6 +100,7 @@ dangerZone: '清除数据', wipeAll: '清除全部', wipeAllAsk: '清除应用的全部数据?金币、成就、联赛和所有游戏进度。', catAll: '全部', catPuzzle: '益智', catBoard: '棋类', catCards: '纸牌', catSkill: '技巧', + peopleAll: '不限人数', people1: '1 人', people2: '2 人', people3: '3–4 人', peopleLabel: '同一部手机上的玩家人数', noFit: '没有符合条件的游戏。', installed: '已安装到设备', playedTimes: '玩过 {n} 次', pinned: '已置顶', recent: '最近玩过', freshRow: '新游戏', pin: '置顶', unpin: '取消置顶', noneYet: '还没有内容', dailyOf: '{d} 的挑战', noGames: '还没有游戏', noGamesSub: '敬请期待', loadFailed: '游戏列表加载失败', @@ -126,6 +129,7 @@ dangerZone: 'Daten löschen', wipeAll: 'Alles löschen', wipeAllAsk: 'Alle App-Daten löschen? Münzen, Erfolge, Liga und jedes Spiel.', catAll: 'Alle', catPuzzle: 'Rätsel', catBoard: 'Brett', catCards: 'Karten', catSkill: 'Geschick', + peopleAll: 'Beliebig viele', people1: '1 Person', people2: '2 Personen', people3: '3–4 Personen', peopleLabel: 'Personen an einem Handy', noFit: 'Kein Spiel passt zu dieser Auswahl.', installed: 'Auf dem Gerät installiert', playedTimes: '{n}-mal gespielt', pinned: 'Angeheftet', recent: 'Zuletzt gespielt', freshRow: 'Neue Spiele', pin: 'Anheften', unpin: 'Lösen', noneYet: 'Noch nichts', dailyOf: 'Rätsel vom {d}', noGames: 'Noch keine Spiele', noGamesSub: 'Kommt bald', loadFailed: 'Spieleliste konnte nicht geladen werden', @@ -154,6 +158,7 @@ dangerZone: 'Borrar datos', wipeAll: 'Borrar todo', wipeAllAsk: '¿Borrar todos los datos de la app? Monedas, logros, liga y todos los juegos.', catAll: 'Todos', catPuzzle: 'Puzles', catBoard: 'Tablero', catCards: 'Cartas', catSkill: 'Habilidad', + peopleAll: 'Cualquier número', people1: '1 jugador', people2: '2 jugadores', people3: '3–4 jugadores', peopleLabel: 'Jugadores en un móvil', noFit: 'Ningún juego encaja con esta elección.', installed: 'Instalada en el dispositivo', playedTimes: 'jugado {n} veces', pinned: 'Fijados', recent: 'Jugados hace poco', freshRow: 'Juegos nuevos', pin: 'Fijar', unpin: 'Soltar', noneYet: 'Aún nada', dailyOf: 'Retos del {d}', noGames: 'Aún no hay juegos', noGamesSub: 'Muy pronto', loadFailed: 'No se pudo cargar la lista de juegos', @@ -285,6 +290,14 @@ // The menu's game categories, in display order; every game in games.json names one as cat (#283) var CATS = ['puzzle', 'board', 'cards', 'skill']; function catKey(c) { return 'cat' + c.charAt(0).toUpperCase() + c.slice(1); } + // How many people a game takes on one phone (#561): 'people' in games.json is the most + // that can play together; '1' lists the games for one person only, '2' and '3' the + // games that take at least that many + var PEOPLE = ['all', '1', '2', '3']; + function peopleFit(g, p) { + var n = g.people || 1; + return p === 'all' || (p === '1' ? n === 1 : n >= +p); + } function sectionHead(title, extra) { return el('div', { class: 'ch-section__head' }, [el('h2', { text: title }), extra || null]); @@ -451,6 +464,15 @@ drawGames(); }); chips.classList.add('ch-seg--wrap', 'ch-cats'); + var people = PEOPLE.indexOf(C.state.settings.gamePeople) >= 0 ? C.state.settings.gamePeople : 'all'; + var pchips = C.ui.segmented(PEOPLE.map(function (p) { return { value: p, label: C.t(p === 'all' ? 'peopleAll' : 'people' + p) }; }), people, function (v) { + people = v; + C.state.settings.gamePeople = v; + C.store.set('settings', C.state.settings); + drawGames(); + }); + pchips.classList.add('ch-seg--wrap', 'ch-cats', 'ch-people'); + pchips.setAttribute('aria-label', C.t('peopleLabel')); // a search covers every category, so the chosen chip never hides a match; it is // not remembered, and redraws only the games so the field keeps its focus var query = ''; @@ -462,6 +484,7 @@ query = searchIn.value; clearBtn.hidden = !query; chips.classList.toggle('ch-cats--off', !!fold(query)); + pchips.classList.toggle('ch-cats--off', !!fold(query)); drawGames(); } searchIn.addEventListener('input', onSearch); @@ -479,26 +502,30 @@ box.appendChild(fgrid); return; } - var shown = cat === 'all' ? CATS : [cat]; + var shown = cat === 'all' ? CATS : [cat], any = false; shown.forEach(function (c) { - var list = GAMES.filter(function (g) { return g.cat === c; }); + var list = GAMES.filter(function (g) { return g.cat === c && peopleFit(g, people); }); if (!list.length) return; var grid = el('div', { class: gridCls }); list.forEach(function (g, i) { grid.appendChild(gameCard(g, i)); }); if (cat === 'all') box.appendChild(el('h3', { class: 'ch-cat__head', text: C.t(catKey(c)) })); box.appendChild(grid); + any = true; }); // a game with no known category is still listed, never lost - var loose = GAMES.filter(function (g) { return CATS.indexOf(g.cat) < 0; }); + var loose = GAMES.filter(function (g) { return CATS.indexOf(g.cat) < 0 && peopleFit(g, people); }); if (loose.length && cat === 'all') { var lgrid = el('div', { class: gridCls }); loose.forEach(function (g, i) { lgrid.appendChild(gameCard(g, i)); }); box.appendChild(lgrid); + any = true; } + if (!any) box.appendChild(el('p', { class: 'ch-search__none', text: C.t('noFit') })); } drawGames(); sec.appendChild(search); sec.appendChild(chips); + sec.appendChild(pchips); sec.appendChild(box); page.appendChild(sec); diff --git a/www/lib/chogan.css b/www/lib/chogan.css index b2af31f2..c6e3b3aa 100644 --- a/www/lib/chogan.css +++ b/www/lib/chogan.css @@ -90,7 +90,7 @@ /* چیدمان */ --w-page: 560px; --w-wide: 900px; - --nav-h: 62px; + --nav-h: 72px; --safe-t: env(safe-area-inset-top, 0px); --safe-b: env(safe-area-inset-bottom, 0px); --safe-l: env(safe-area-inset-left, 0px); @@ -255,6 +255,8 @@ a { color: var(--c-accent); text-decoration: none; } @media (max-width: 360px) { .ch-seg.ch-cats button { padding-inline: 7px; } } .ch-cat__head { font-size: var(--fs-md); color: var(--c-text-dim); margin: var(--s-5) 0 var(--s-3); } .ch-cats + .ch-cat__head { margin-top: 0; } +/* the players row (#561) sits close under the categories, as one filter block */ +.ch-cats + .ch-people { margin-top: calc(var(--s-2) - var(--s-4)); } /* Search by name above the chips (#521). 16px text, or iOS zooms the page on focus; while a search is on, the chips step back because the results ignore them. */ .ch-search { @@ -474,20 +476,27 @@ a { color: var(--c-accent); text-decoration: none; } .ch-btn__ripple { position: relative; overflow: hidden; } /* --------------------------------------------------------- نوار ناوبری */ +/* A floating, rounded bar with room around it (#559): edge to edge it stretched + across a whole tablet. It is centred by its physical left, so it sits in the + middle in both reading directions. */ .ch-nav { position: fixed; - inset-inline: 0; - bottom: 0; + left: 50%; + bottom: calc(12px + var(--safe-b)); + transform: translateX(-50%); + width: min(calc(100% - 24px - var(--safe-l) - var(--safe-r)), 480px); z-index: 40; display: flex; justify-content: center; gap: 2px; - padding: 6px calc(var(--s-2) + var(--safe-r)) calc(6px + var(--safe-b)) calc(var(--s-2) + var(--safe-l)); + padding: 4px 8px; background: var(--c-surface); background: color-mix(in srgb, var(--c-surface) 88%, transparent); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); - border-top: 1px solid var(--c-line); + border: 1px solid var(--c-line); + border-radius: 24px; + box-shadow: 0 8px 28px rgba(0, 0, 0, .18), 0 2px 6px rgba(0, 0, 0, .08); } .ch-nav__in { display: flex; width: 100%; max-width: var(--w-page); } .ch-nav__item { @@ -502,8 +511,8 @@ a { color: var(--c-accent); text-decoration: none; } justify-content: center; gap: 2px; padding: 4px 2px; - min-height: 50px; - border-radius: var(--r-sm); + min-height: 48px; + border-radius: 18px; cursor: pointer; font-size: 11px; font-weight: 700; @@ -511,7 +520,7 @@ a { color: var(--c-accent); text-decoration: none; } touch-action: manipulation; } .ch-nav__item svg { width: 24px; height: 24px; display: block; transition: transform var(--t-mid) var(--ease-back); } -.ch-nav__item[aria-selected="true"] { color: var(--c-accent); } +.ch-nav__item[aria-selected="true"] { color: var(--c-accent); background: color-mix(in srgb, var(--c-accent) 14%, transparent); } .ch-nav__item[aria-selected="true"] svg { transform: translateY(-2px) scale(1.08); } .ch-nav__item:active svg { transform: scale(.9); } /* A phone turned sideways (#538): the bottom bar took 62px of a ~360px tall @@ -520,14 +529,19 @@ a { color: var(--c-accent); text-decoration: none; } @media (orientation: landscape) and (max-height: 540px) { :root { --nav-w: 76px; } .ch-nav { + left: auto; inset-inline: auto; inset-inline-start: 0; top: 0; + bottom: 0; + transform: none; width: var(--nav-w); flex-direction: column; padding: calc(6px + var(--safe-t)) 6px calc(6px + var(--safe-b)); - border-top: 0; + border: 0; border-inline-end: 1px solid var(--c-line); + border-radius: 0; + box-shadow: none; } /* the notch side of a turned phone is a physical side, not a reading one */ [dir="ltr"] .ch-nav { width: calc(var(--nav-w) + var(--safe-l)); padding-left: calc(6px + var(--safe-l)); } @@ -920,7 +934,8 @@ a { color: var(--c-accent); text-decoration: none; } display: flex; align-items: center; gap: var(--s-2); - max-width: 520px; + /* as wide as the floating bar below it (#559) */ + max-width: 480px; margin-inline: auto; padding: 8px 8px 8px var(--s-3); background: var(--c-surface); @@ -1220,7 +1235,6 @@ a { color: var(--c-accent); text-decoration: none; } /* --------------------------------------------------------- دسکتاپ */ @media (min-width: 900px) { :root { --fs-3xl: 48px; } - .ch-nav { padding-bottom: 8px; } } @media (hover: hover) { .ch-tap:hover { transform: translateY(-2px); box-shadow: var(--sh-3); } diff --git a/www/lib/chogan.js b/www/lib/chogan.js index 3d9e4e69..8d28c2a3 100644 --- a/www/lib/chogan.js +++ b/www/lib/chogan.js @@ -285,6 +285,7 @@ menu: 'منو', back: 'بازگشت', undo: 'برگرداندن', close: 'بستن', cancel: 'انصراف', result: 'نتیجه', seeBoard: 'دیدن صفحه‌ی بازی', saveImage: 'ذخیره‌ی عکس', copyText: 'کپی متن', updateReady: 'نسخه‌ی تازه آماده است', updateNow: 'به‌روزرسانی', + installReady: 'چوگان را مثل یک اپ نصب کن', installNow: 'نصب', confirm: 'تأیید', done: 'باشه', next: 'بعدی', skip: 'رد کردن', start: 'شروع', pause: 'مکث', resumeGame: 'ادامه‌ی بازی', restart: 'شروع دوباره', quit: 'خروج به منو', help: 'راهنما', share: 'اشتراک‌گذاری', copied: 'در حافظه کپی شد', imageSaved: 'عکس در گالری ذخیره شد', @@ -326,6 +327,7 @@ menu: 'Menu', back: 'Back', undo: 'Undo', close: 'Close', cancel: 'Cancel', result: 'Result', seeBoard: 'See the board', saveImage: 'Save image', copyText: 'Copy text', updateReady: 'A new version is ready', updateNow: 'Update', + installReady: 'Install Chogan as an app', installNow: 'Install', confirm: 'Confirm', done: 'OK', next: 'Next', skip: 'Skip', start: 'Start', pause: 'Pause', resumeGame: 'Resume', restart: 'Restart', quit: 'Quit to menu', help: 'Help', share: 'Share', copied: 'Copied to clipboard', imageSaved: 'Saved to your gallery', @@ -367,6 +369,7 @@ menu: '菜单', back: '返回', undo: '撤销', close: '关闭', cancel: '取消', result: '结果', seeBoard: '查看棋盘', saveImage: '保存图片', copyText: '复制文字', updateReady: '新版本已就绪', updateNow: '更新', + installReady: '将 Chogan 安装为应用', installNow: '安装', confirm: '确定', done: '好', next: '下一步', skip: '跳过', start: '开始', pause: '暂停', resumeGame: '继续游戏', restart: '重新开始', quit: '退出到菜单', help: '帮助', share: '分享', copied: '已复制到剪贴板', imageSaved: '已保存到相册', @@ -408,6 +411,7 @@ menu: 'Menü', back: 'Zurück', undo: 'Rückgängig', close: 'Schließen', cancel: 'Abbrechen', result: 'Ergebnis', seeBoard: 'Spielfeld ansehen', saveImage: 'Bild speichern', copyText: 'Text kopieren', updateReady: 'Eine neue Version ist da', updateNow: 'Aktualisieren', + installReady: 'Chogan als App installieren', installNow: 'Installieren', confirm: 'Bestätigen', done: 'OK', next: 'Weiter', skip: 'Überspringen', start: 'Start', pause: 'Pause', resumeGame: 'Weiter', restart: 'Neu starten', quit: 'Zurück zum Menü', help: 'Hilfe', share: 'Teilen', copied: 'In die Zwischenablage kopiert', imageSaved: 'In der Galerie gespeichert', @@ -449,6 +453,7 @@ menu: 'Menú', back: 'Atrás', undo: 'Deshacer', close: 'Cerrar', cancel: 'Cancelar', result: 'Resultado', seeBoard: 'Ver el tablero', saveImage: 'Guardar imagen', copyText: 'Copiar texto', updateReady: 'Hay una versión nueva', updateNow: 'Actualizar', + installReady: 'Instala Chogan como app', installNow: 'Instalar', confirm: 'Confirmar', done: 'OK', next: 'Siguiente', skip: 'Saltar', start: 'Empezar', pause: 'Pausa', resumeGame: 'Seguir', restart: 'Reiniciar', quit: 'Salir al menú', help: 'Ayuda', share: 'Compartir', copied: 'Copiado al portapapeles', imageSaved: 'Guardada en la galería', @@ -1109,6 +1114,12 @@ { id: 'smn-10', icon: 'star', fa: 'حافظه‌ی ده‌تایی', en: 'Ten in a row', dfa: 'در سایمون به دور ۱۰ رسیدی', den: 'Reach round 10 in Echo', zh: '十连', dzh: '在闪灯记忆中到达第 10 轮', de: 'Zehn am Stück', dde: 'Erreiche bei Echo Runde 10', es: 'Diez seguidos', des: 'Llega a la ronda 10 en Eco' }, { id: 'smn-20', icon: 'brain', fa: 'حافظه‌ی آهنین', en: 'Iron memory', dfa: 'در سایمون به دور ۲۰ رسیدی', den: 'Reach round 20 in Echo', zh: '钢铁记忆', dzh: '在闪灯记忆中到达第 20 轮', de: 'Eisernes Gedächtnis', dde: 'Erreiche bei Echo Runde 20', es: 'Memoria de hierro', des: 'Llega a la ronda 20 en Eco' }, { id: 'smn-daily', icon: 'calendar', fa: 'پژواک روز', en: 'Echo of the day', dfa: 'در سایمون روزانه به دور ۸ رسیدی', den: 'Reach round 8 in a daily Echo', zh: '每日回响', dzh: '在每日闪灯记忆中到达第 8 轮', de: 'Echo des Tages', dde: 'Erreiche bei einem Tages-Echo Runde 8', es: 'Eco del día', des: 'Llega a la ronda 8 en un Eco diario' }, + { id: 'lit-perfect', icon: 'star', fa: 'بی‌حرکت اضافه', en: 'Not one too many', dfa: 'یک خاموشی ۵×۵ یا بزرگ‌تر را با کمترین حرکت حل کردی', den: 'Solve a 5×5 or larger Lights Off in the fewest moves', zh: '一步不多', dzh: '用最少步数解开一道 5×5 或更大的关灯', de: 'Kein Zug zu viel', dde: 'Löse ein Lichter aus ab 5×5 mit den wenigsten Zügen', es: 'Ni uno de más', des: 'Resuelve un Apagón de 5×5 o más con el mínimo de movimientos' }, + { id: 'lit-big', icon: 'brain', fa: 'شهر خاموش', en: 'Blackout', dfa: 'یک خاموشی ۷×۷ را حل کردی', den: 'Solve a 7×7 Lights Off', zh: '全城熄灯', dzh: '解开一道 7×7 的关灯', de: 'Stromausfall', dde: 'Löse ein Lichter aus in 7×7', es: 'Ciudad a oscuras', des: 'Resuelve un Apagón de 7×7' }, + { id: 'lit-daily', icon: 'calendar', fa: 'خاموشی روز', en: 'Lights out today', dfa: 'خاموشی روزانه را با کمترین حرکت حل کردی', den: 'Solve the daily Lights Off in the fewest moves', zh: '今日熄灯', dzh: '用最少步数解开每日关灯', de: 'Heute alles aus', dde: 'Löse das tägliche Lichter aus mit den wenigsten Zügen', es: 'Apagón del día', des: 'Resuelve el Apagón diario con el mínimo de movimientos' }, + { id: 'cor-win', icon: 'trophy', fa: 'آن طرف راهرو', en: 'Through the corridor', dfa: 'یک بازی راهرو را مقابل ربات بردی', den: 'Win a game of Corridor against the bot', zh: '穿过走廊', dzh: '对战电脑赢下一局走廊', de: 'Durch den Korridor', dde: 'Gewinne eine Partie Korridor gegen den Bot', es: 'Al otro lado', des: 'Gana una partida de Pasillo contra el bot' }, + { id: 'cor-hard', icon: 'brain', fa: 'معمار دیوارها', en: 'Master builder', dfa: 'راهرو را مقابل ربات سخت بردی', den: 'Beat the hard bot at Corridor', zh: '筑墙大师', dzh: '在走廊中战胜困难电脑', de: 'Baumeister', dde: 'Schlage den starken Bot bei Korridor', es: 'Maestro de obras', des: 'Gana al bot difícil en Pasillo' }, + { id: 'cor-flag', icon: 'flag', fa: 'زیر پرچم', en: 'Under the flag', dfa: 'یک بازی پرچم راهرو را مقابل ربات بردی', den: 'Win a flag game of Corridor against the bot', zh: '旗下', dzh: '对战电脑赢下一局带旗子的走廊', de: 'Unter der Fahne', dde: 'Gewinne ein Fahnenspiel Korridor gegen den Bot', es: 'Bajo la bandera', des: 'Gana una partida de Pasillo con bandera contra el bot' }, { id: 'go-win', icon: 'medal', fa: 'زمین‌گیر', en: 'Territory', dfa: 'یک دست گو را مقابل ربات بردی', den: 'Win a game of go against the AI', zh: '围地', dzh: '对战电脑赢下一局围棋', de: 'Gebietsherr', dde: 'Gewinne eine Partie Go gegen den Computer', es: 'Territorio', des: 'Gana una partida de Go contra la IA' }, { id: 'go-hard', icon: 'brain', fa: 'دو چشم', en: 'Two eyes', dfa: 'در گو ربات سخت را شکست دادی', den: 'Beat the hard AI at go', zh: '两只眼', dzh: '在围棋中击败困难电脑', de: 'Zwei Augen', dde: 'Schlag die schwere Go-KI', es: 'Dos ojos', des: 'Vence a la IA difícil en Go' }, { id: 'bj-win', icon: 'trophy', fa: 'دیلر را بردی', en: 'Beat the dealer', dfa: 'یک مسابقه‌ی ده‌دستی بیست‌ویک را بردی', den: 'Win a ten-hand blackjack match', zh: '赢过庄家', dzh: '赢下一场十手的二十一点比赛', de: 'Croupier geschlagen', dde: 'Gewinne eine Blackjack-Partie aus zehn Händen', es: 'Vence a la banca', des: 'Gana una partida de Blackjack de diez manos' }, @@ -2250,8 +2261,10 @@ // نوار آپدیت. اعلان موقت به درد نمی‌خورد چون تا کاربر تصمیم نگیرد باید بماند. ui.updateBar = function (onGo) { - var existing = document.querySelector('.ch-update'); + var existing = document.querySelector('.ch-update:not(.ch-install)'); if (existing) return existing; + // a new version matters more than installing, and the two would sit on each other + ui.installBar.close(); var bar = el('div', { class: 'ch-update', role: 'status' }); var go = el('button', { class: 'ch-btn ch-btn--primary ch-btn--sm', type: 'button', text: Chogan.t('updateNow') }); var x = el('button', { class: 'ch-iconbtn ch-iconbtn--plain', type: 'button', 'aria-label': Chogan.t('close') }, @@ -2271,6 +2284,31 @@ return bar; }; + // The install bar (#557) looks like the update bar. onGo opens the browser's own + // install question and may return a promise; onNo is a tap on the cross. + ui.installBar = function (onGo, onNo) { + if (document.querySelector('.ch-update')) return null; + var bar = el('div', { class: 'ch-update ch-install', role: 'status' }); + var go = el('button', { class: 'ch-btn ch-btn--primary ch-btn--sm', type: 'button', text: Chogan.t('installNow') }); + var x = el('button', { class: 'ch-iconbtn ch-iconbtn--plain', type: 'button', 'aria-label': Chogan.t('close') }, + [Chogan.icon('close', 18)]); + go.addEventListener('click', function () { + Chogan.feedback('tap'); + go.disabled = true; + Promise.resolve(onGo && onGo()).then(ui.installBar.close, ui.installBar.close); + }); + x.addEventListener('click', function () { Chogan.feedback('tap'); if (onNo) onNo(); ui.installBar.close(); }); + bar.appendChild(el('div', { class: 'ch-update__t', text: Chogan.t('installReady') })); + bar.appendChild(go); + bar.appendChild(x); + document.body.appendChild(bar); + return bar; + }; + ui.installBar.close = function () { + var bar = document.querySelector('.ch-install'); + if (bar && bar.parentNode) bar.parentNode.removeChild(bar); + }; + ui.toast = function (o) { var host = ui.toastHost(); // همان پیام دوباره روی هم چیده نمی‌شود: پیام موجود دوباره زمان می‌گیرد. با چند ضربه‌ی @@ -3101,6 +3139,24 @@ document.removeEventListener('keydown', unlockAudioOnce); } + // Offer to install the web app from the menu (#557), only when the browser itself + // offers it (Chrome and Edge fire beforeinstallprompt), never inside the Android app, + // and a tap on the cross hides it for good. + Chogan.installOffer = function (win) { + if (!win || !win.addEventListener || win.Capacitor) return; + win.addEventListener('beforeinstallprompt', function (e) { + if (Chogan.store.get('installNo', false)) return; + // the browser's own mini-bar would be a second one on top of ours + e.preventDefault(); + Chogan.ui.installBar(function () { + e.prompt(); + return e.userChoice; + }, function () { Chogan.store.set('installNo', true); }); + }); + win.addEventListener('appinstalled', function () { Chogan.ui.installBar.close(); }); + }; + /* end of the install offer */ + Chogan.boot = function (o) { o = o || {}; // صفحه‌ی منو. صفحه‌ی بازی خودش در Chogan.game عنوان دقیق‌ترش را می‌گذارد. @@ -3127,6 +3183,7 @@ if (mq.addEventListener) mq.addEventListener('change', onch); else if (mq.addListener) mq.addListener(onch); } + if (o.sw) Chogan.installOffer(global); // سرویس‌ورکر فقط برای نسخه‌ی وب و فقط از صفحه‌ی منو ثبت می‌شود. // داخل اپ اندروید فایل‌ها از قبل محلی‌اند و لایه‌ی کش لازم نیست. if (o.sw && !global.Capacitor && 'serviceWorker' in global.navigator && diff --git a/www/version.js b/www/version.js index fa9e536e..9968cdf8 100644 --- a/www/version.js +++ b/www/version.js @@ -1,5 +1,5 @@ // تنها منبع نسخه در کل ریپو. // گریدل (versionName و versionCode)، منو و سرویس‌ورکر همین فایل را می‌خوانند. // با هر انتشار هر دو مقدار را بالا ببر و تگ گیت را دقیقاً v بگذار. -self.APP_VERSION = '0.19.0'; -self.APP_VERSION_CODE = 40; +self.APP_VERSION = '0.20.0'; +self.APP_VERSION_CODE = 41;