Looping mode

This commit is contained in:
Renan LE CARO 2025-03-28 19:40:59 +01:00
parent 3d5547e786
commit 5012076039
21 changed files with 2852 additions and 2696 deletions

View file

@ -14,16 +14,33 @@ Break colourful bricks, catch bouncing coins and select powerful upgrades !
- [HackerNews thread](https://news.ycombinator.com/item?id=43183131) - [HackerNews thread](https://news.ycombinator.com/item?id=43183131)
# Premium: allow looping
Allow players to loop the game :
- [x] keep your score
- [x] keep 1 perk
- [x] add one hasard
- [x] add one HP to all bricks - as a debuff
- [ ] advertise looping in normal game over screen
- real time stats as the option says.
- [x] Noise of coins against side is annoying.
- Change look of loop, to avoid picking randomly at loop end.
- make red coins scarier,
- add blue coins that only freeze puck.
- Make fullscreen an option and turn it back on when playing
- +1 combo de base par rerolls
- +1 combo de base par vie restantes (pas attrapable)
# Todo # Todo
- [jaceys] Counters for coins lost, misses, and boundary bounces, as well as a timer.
- people assume unbounded allows for wrap around - people assume unbounded allows for wrap around
- coin magnet and viscosity : only one level ~2.5 - coin magnet and viscosity : only one level ~2.5
- Boost Ascetism : give +2 or even +3 combo per brick destroyed - Boost Ascetism : give +2 or even +3 combo per brick destroyed
- wind : move coins based on puck movement not position - wind : move coins based on puck movement not position
- show -N points in red when combo resets - show -N points in red when combo resets
- reach : this is too punishing now, maybe only reset if you hit the lowest populate row of the level, if it's not a full width row - reach is too punishing now, maybe only reset if you hit the lowest populate row of the level, if it's not a full width row
- respawn: N% of bricks respawn after N seconds - respawn: N% of bricks respawn after N seconds
- [jaceys] Counters for coins lost, misses, and boundary bounces, as well as a timer.
- [jaceys] Move the restart button out of the menu, so that it is more easily accessible - [jaceys] Move the restart button out of the menu, so that it is more easily accessible
- [jaceys] A visual indication of whether a ball has hit a brick this serve - [jaceys] A visual indication of whether a ball has hit a brick this serve
- [obigre] Offer to level ups perks separately - [obigre] Offer to level ups perks separately
@ -31,17 +48,6 @@ Break colourful bricks, catch bouncing coins and select powerful upgrades !
- https://weblate.org/fr/ - https://weblate.org/fr/
# Premium: allow looping
Allow players to loop the game :
- [x] keep your score
- [x] keep 1 perk
- [x] add one hasard
- [ ] add one HP to all bricks
- [ ] advertise looping in normal game over screen
- [ ] save score at the end of first loop, in addition to the final one ?
- [ ] check that stats like max level are correct
# System requirements # System requirements

View file

@ -11,8 +11,8 @@ android {
applicationId = "me.lecaro.breakout" applicationId = "me.lecaro.breakout"
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 29050375 versionCode = 29053110
versionName = "29050375" versionName = "29053110"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true

File diff suppressed because one or more lines are too long

102
dist/index.html vendored

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
// The version of the cache. // The version of the cache.
const VERSION = "29050375"; const VERSION = "29053110";
// The name of the cache // The name of the cache
const CACHE_NAME = `breakout-71-${VERSION}`; const CACHE_NAME = `breakout-71-${VERSION}`;

View file

@ -1046,4 +1046,4 @@
"svg": null, "svg": null,
"color": "" "color": ""
} }
] ]

View file

@ -1 +1 @@
"29050375" "29053110"

View file

@ -1,11 +1,11 @@
import { t } from "./i18n/i18n"; import { t } from "./i18n/i18n";
import {Debuff} from "./types"; import { Debuff } from "./types";
export const debuffs = [ export const debuffs = [
{ {
id: "negative_coins", id: "negative_coins",
max: 20, max: 20,
name: (lvl: number) => t("debuffs.negative_coins.help",{lvl}), name: (lvl: number) => t("debuffs.negative_coins.help", { lvl }),
help: (lvl: number) => t("debuffs.negative_coins.help", { lvl }), help: (lvl: number) => t("debuffs.negative_coins.help", { lvl }),
}, },
{ {
@ -17,8 +17,10 @@ export const debuffs = [
{ {
id: "banned", id: "banned",
max: 50, max: 50,
name: (lvl: number,banned:string) => t("debuffs.banned.description",{lvl,banned}), name: (lvl: number, banned: string) =>
help: (lvl: number,perk:string) => t("debuffs.banned.help", { lvl,perk }), t("debuffs.banned.description", { lvl, banned }),
help: (lvl: number, perk: string) =>
t("debuffs.banned.help", { lvl, perk }),
}, },
{ {
id: "interference", id: "interference",
@ -30,8 +32,14 @@ export const debuffs = [
{ {
id: "fragility", id: "fragility",
max: 5, max: 5,
name: (lvl: number) => t("debuffs.fragility.help", { percent:lvl*20 }), name: (lvl: number) => t("debuffs.fragility.help", { percent: lvl * 20 }),
help: (lvl: number) => t("debuffs.fragility.help", { percent:lvl*20 }), help: (lvl: number) => t("debuffs.fragility.help", { percent: lvl * 20 }),
},
{
id: "sturdiness",
max: 5,
name: (lvl: number) => t("debuffs.sturdiness.help", { lvl }),
help: (lvl: number) => t("debuffs.sturdiness.help", { lvl }),
}, },
] as const as Debuff[]; ] as const as Debuff[];

View file

@ -14,7 +14,8 @@ import {
import { getAudioContext, playPendingSounds } from "./sounds"; import { getAudioContext, playPendingSounds } from "./sounds";
import { import {
bannedUpgradesHTMl, bannedUpgradesHTMl,
currentLevelInfo, debuffsHTMl, currentLevelInfo,
debuffsHTMl,
getRowColIndex, getRowColIndex,
levelsListHTMl, levelsListHTMl,
max_levels, max_levels,
@ -447,25 +448,24 @@ document.addEventListener("visibilitychange", () => {
async function openScorePanel() { async function openScorePanel() {
pause(true); pause(true);
const cb = await asyncAlert({ const cb = await asyncAlert({
title: title: gameState.loop
gameState.loop ? ? t("score_panel.title_looped", {
t("score_panel.title_looped", { loop: gameState.loop,
loop:gameState.loop,
score: gameState.score, score: gameState.score,
level: gameState.currentLevel + 1, level: gameState.currentLevel + 1,
max: max_levels(gameState), max: max_levels(gameState),
}): })
t("score_panel.title", { : t("score_panel.title", {
score: gameState.score, score: gameState.score,
level: gameState.currentLevel + 1, level: gameState.currentLevel + 1,
max: max_levels(gameState), max: max_levels(gameState),
}), }),
content: [ content: [
gameState.isCreativeModeRun ? `<p>${t("score_panel.test_run")}</p>` : "", gameState.isCreativeModeRun ? `<p>${t("score_panel.test_run")}</p>` : "",
pickedUpgradesHTMl(gameState), pickedUpgradesHTMl(gameState),
levelsListHTMl(gameState), levelsListHTMl(gameState),
debuffsHTMl(gameState), debuffsHTMl(gameState),
], ],
allowClose: true, allowClose: true,
}); });
@ -1013,22 +1013,23 @@ restart(
// // unbounded: 1, // // unbounded: 1,
// // pierce_color: 1, // // pierce_color: 1,
// pierce: 1, // pierce: 1,
streak_shots:1, // streak_shots: 1,
// multiball: 6, // multiball: 6,
// base_combo: 7, base_combo: 7,
// telekinesis: 2, telekinesis: 2,
// yoyo: 2, yoyo: 2,
pierce:10, pierce: 10,
// metamorphosis: 1, // metamorphosis: 1,
// implosions: 1, // implosions: 1,
// sturdy_bricks:5 // sturdy_bricks:5
extra_life:3 coin_magnet:2,
extra_life: 3,
}, },
debuffs:{ debuffs: {
// fragility:3 // fragility:3
negative_coins:1 negative_coins: 100,
// interference:20, // interference:20,
} },
}) || }) ||
{}, {},
); );

View file

@ -136,7 +136,7 @@ export function gameOver(title: string, intro: string) {
], ],
}).then(() => }).then(() =>
restart({ restart({
levelToAvoid: currentLevelInfo(gameState).name levelToAvoid: currentLevelInfo(gameState).name,
}), }),
); );
} }
@ -271,6 +271,7 @@ export function getHistograms() {
(r) => r.max_combo, (r) => r.max_combo,
"", "",
); );
runStats += makeHistogram(t("gameOver.stats.loops"), (r) => r.loops, "");
if (runStats) { if (runStats) {
runStats = runStats =

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import { Ball, GameState, PerkId, PerksMap } from "./types"; import { Ball, GameState, PerkId, PerksMap } from "./types";
import { icons, upgrades } from "./loadGameData"; import { icons, upgrades } from "./loadGameData";
import { t } from "./i18n/i18n"; import { t } from "./i18n/i18n";
import {debuffs} from "./debuffs"; import { debuffs } from "./debuffs";
export function getMajorityValue(arr: string[]): string { export function getMajorityValue(arr: string[]): string {
const count: { [k: string]: number } = {}; const count: { [k: string]: number } = {};
@ -55,7 +55,6 @@ export function getPossibleUpgrades(gameState: GameState) {
} }
export function max_levels(gameState: GameState) { export function max_levels(gameState: GameState) {
return 7 + gameState.perks.extra_levels; return 7 + gameState.perks.extra_levels;
} }
@ -70,10 +69,15 @@ export function pickedUpgradesHTMl(gameState: GameState) {
return ` <p>${t("score_panel.upgrades_picked")}</p> <p>${list}</p>`; return ` <p>${t("score_panel.upgrades_picked")}</p> <p>${list}</p>`;
} }
export function debuffsHTMl(gameState: GameState): string {
export function debuffsHTMl(gameState: GameState):string { const banned = upgrades
const banned = upgrades.filter(u=>gameState.bannedPerks[u.id]).map(u=>u.name).join(', ') .filter((u) => gameState.bannedPerks[u.id])
let list = debuffs.filter(d=>gameState.debuffs[d.id]).map(d=>d.name(gameState.debuffs[d.id], banned)).join(' '); .map((u) => u.name)
.join(", ");
let list = debuffs
.filter((d) => gameState.debuffs[d.id])
.map((d) => d.name(gameState.debuffs[d.id], banned))
.join(" ");
if (!list) return ""; if (!list) return "";
return `<p>${t("score_panel.bebuffs_list")} ${list}</p>`; return `<p>${t("score_panel.bebuffs_list")} ${list}</p>`;

View file

@ -202,6 +202,26 @@
</concept_node> </concept_node>
</children> </children>
</folder_node> </folder_node>
<folder_node>
<name>sturdiness</name>
<children>
<concept_node>
<name>help</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children> </children>
</folder_node> </folder_node>
<folder_node> <folder_node>
@ -470,6 +490,21 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>loops</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>total_score</name> <name>total_score</name>
<description/> <description/>
@ -787,6 +822,21 @@
<folder_node> <folder_node>
<name>loop</name> <name>loop</name>
<children> <children>
<concept_node>
<name>converted_rerolls</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>instructions</name> <name>instructions</name>
<description/> <description/>
@ -802,6 +852,21 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>no_rerolls</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>title</name> <name>title</name>
<description/> <description/>

View file

@ -9,6 +9,7 @@
"debuffs.interference.help": "Telekinesis and yo-yo glitch for {{lvl}}s every 7s.", "debuffs.interference.help": "Telekinesis and yo-yo glitch for {{lvl}}s every 7s.",
"debuffs.more_bombs.help": "{{lvl}} bricks replaced by bombs.", "debuffs.more_bombs.help": "{{lvl}} bricks replaced by bombs.",
"debuffs.negative_coins.help": "{{lvl}}/10000 of coins spawn cursed, blinking red. Game over if you catch them.", "debuffs.negative_coins.help": "{{lvl}}/10000 of coins spawn cursed, blinking red. Game over if you catch them.",
"debuffs.sturdiness.help": "All bricks have +{{lvl}} HP",
"gameOver.because_cursed_coin": "Game over", "gameOver.because_cursed_coin": "Game over",
"gameOver.because_cursed_coin_intro": "You cough a cursed coin (bright red coins) and didn't have a extra life to spare. ", "gameOver.because_cursed_coin_intro": "You cough a cursed coin (bright red coins) and didn't have a extra life to spare. ",
"gameOver.cumulative_total": "Your total cumulative score went from {{startTs}} to {{endTs}}.", "gameOver.cumulative_total": "Your total cumulative score went from {{startTs}} to {{endTs}}.",
@ -26,6 +27,7 @@
"gameOver.stats.hit_rate": "Hit rate", "gameOver.stats.hit_rate": "Hit rate",
"gameOver.stats.intro": "Find below your run statistics compared to your {{count}} best runs.", "gameOver.stats.intro": "Find below your run statistics compared to your {{count}} best runs.",
"gameOver.stats.level_reached": "Level reached", "gameOver.stats.level_reached": "Level reached",
"gameOver.stats.loops": "Loops",
"gameOver.stats.total_score": "Total score", "gameOver.stats.total_score": "Total score",
"gameOver.stats.upgrades_applied": "Upgrades applied", "gameOver.stats.upgrades_applied": "Upgrades applied",
"gameOver.test_run": "This test run and its score are not being recorded", "gameOver.test_run": "This test run and its score are not being recorded",
@ -46,7 +48,9 @@
"level_up.unlocked_level": " (Level)", "level_up.unlocked_level": " (Level)",
"level_up.unlocked_perk": " (Perk)", "level_up.unlocked_perk": " (Perk)",
"level_up.upgrade_perk_to_level": " lvl {{level}}", "level_up.upgrade_perk_to_level": " lvl {{level}}",
"loop.converted_rerolls": "Your {{n}} leftover re-rolls where converted to +{{n}} base combo.",
"loop.instructions": "All your perks will be erased except one that you can pick below. Each option comes with an additional hazard will appear on all levels going forward. ", "loop.instructions": "All your perks will be erased except one that you can pick below. Each option comes with an additional hazard will appear on all levels going forward. ",
"loop.no_rerolls": "You didn't have any leftover re-rolls, so your base combo stayed the same. ",
"loop.title": "Starting loop {{loop}}", "loop.title": "Starting loop {{loop}}",
"main_menu.basic": "Basic graphics", "main_menu.basic": "Basic graphics",
"main_menu.basic_help": "Better performance.", "main_menu.basic_help": "Better performance.",

View file

@ -9,6 +9,7 @@
"debuffs.interference.help": "Télékinésie et problème de yo-yo pendant {{lvl}}s toutes les 7 s.", "debuffs.interference.help": "Télékinésie et problème de yo-yo pendant {{lvl}}s toutes les 7 s.",
"debuffs.more_bombs.help": "{{lvl}} briques remplacées par des bombes.", "debuffs.more_bombs.help": "{{lvl}} briques remplacées par des bombes.",
"debuffs.negative_coins.help": "{{lvl}}/10000 pièces apparaissent maudites et clignotent en rouge. La partie est terminée si vous les attrapez.", "debuffs.negative_coins.help": "{{lvl}}/10000 pièces apparaissent maudites et clignotent en rouge. La partie est terminée si vous les attrapez.",
"debuffs.sturdiness.help": "Toutes les briques résistent à +{{lvl}} chocs",
"gameOver.because_cursed_coin": "Jeu terminé", "gameOver.because_cursed_coin": "Jeu terminé",
"gameOver.because_cursed_coin_intro": "Vous avez craché une pièce maudite (pièces rouge vif) et vous n'aviez pas de vie supplémentaire à revendre.", "gameOver.because_cursed_coin_intro": "Vous avez craché une pièce maudite (pièces rouge vif) et vous n'aviez pas de vie supplémentaire à revendre.",
"gameOver.cumulative_total": "Votre score total cumulé est passé de {{startTs}} à {{endTs}}.", "gameOver.cumulative_total": "Votre score total cumulé est passé de {{startTs}} à {{endTs}}.",
@ -26,6 +27,7 @@
"gameOver.stats.hit_rate": "Précision", "gameOver.stats.hit_rate": "Précision",
"gameOver.stats.intro": "Vous trouverez ci-dessous les statistiques de cette partie comparées à vos {{count}} meilleures parties.", "gameOver.stats.intro": "Vous trouverez ci-dessous les statistiques de cette partie comparées à vos {{count}} meilleures parties.",
"gameOver.stats.level_reached": "Niveau atteint", "gameOver.stats.level_reached": "Niveau atteint",
"gameOver.stats.loops": "Boucles",
"gameOver.stats.total_score": "Score total", "gameOver.stats.total_score": "Score total",
"gameOver.stats.upgrades_applied": "Mises à jour appliquées", "gameOver.stats.upgrades_applied": "Mises à jour appliquées",
"gameOver.test_run": "Cette partie de test et son score ne sont pas enregistrés.", "gameOver.test_run": "Cette partie de test et son score ne sont pas enregistrés.",
@ -46,7 +48,9 @@
"level_up.unlocked_level": " (Niveau)", "level_up.unlocked_level": " (Niveau)",
"level_up.unlocked_perk": " (Amélioration)", "level_up.unlocked_perk": " (Amélioration)",
"level_up.upgrade_perk_to_level": " niveau {{level}}", "level_up.upgrade_perk_to_level": " niveau {{level}}",
"loop.converted_rerolls": "",
"loop.instructions": "Tous vos avantages seront supprimés, sauf un que vous pouvez choisir ci-dessous. Chaque option comporte un danger supplémentaire qui apparaîtra à tous les niveaux.", "loop.instructions": "Tous vos avantages seront supprimés, sauf un que vous pouvez choisir ci-dessous. Chaque option comporte un danger supplémentaire qui apparaîtra à tous les niveaux.",
"loop.no_rerolls": "",
"loop.title": "Boucle de départ {{loop}}", "loop.title": "Boucle de départ {{loop}}",
"main_menu.basic": "Graphismes simplifiés", "main_menu.basic": "Graphismes simplifiés",
"main_menu.basic_help": "Meilleures performances.", "main_menu.basic_help": "Meilleures performances.",

View file

@ -45,5 +45,5 @@ export const allLevels = rawLevelsList
export const upgrades = rawUpgrades.map((u) => ({ export const upgrades = rawUpgrades.map((u) => ({
...u, ...u,
icon: icons["icon:" + u.id] icon: icons["icon:" + u.id],
})) as Upgrade[]; })) as Upgrade[];

View file

@ -11,10 +11,9 @@ import { dontOfferTooSoon, resetBalls } from "./gameStateMutators";
import { isOptionOn } from "./options"; import { isOptionOn } from "./options";
import { debuffs } from "./debuffs"; import { debuffs } from "./debuffs";
export function getRunLevels(totalScoreAtRunStart: number, params: RunParams) {
export function getRunLevels(totalScoreAtRunStart:number, params: RunParams){ const firstLevel = params?.level
const firstLevel = ? allLevels.filter((l) => l.name === params?.level)
params?.level ? allLevels.filter((l) => l.name === params?.level)
: []; : [];
const restInRandomOrder = allLevels const restInRandomOrder = allLevels
@ -23,7 +22,7 @@ export function getRunLevels(totalScoreAtRunStart:number, params: RunParams){
.filter((l) => l.name !== params?.levelToAvoid) .filter((l) => l.name !== params?.levelToAvoid)
.sort(() => Math.random() - 0.5); .sort(() => Math.random() - 0.5);
return firstLevel.concat( return firstLevel.concat(
restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey), restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey),
); );
} }
@ -31,7 +30,7 @@ return firstLevel.concat(
export function newGameState(params: RunParams): GameState { export function newGameState(params: RunParams): GameState {
const totalScoreAtRunStart = getTotalScore(); const totalScoreAtRunStart = getTotalScore();
const runLevels =getRunLevels(totalScoreAtRunStart, params) const runLevels = getRunLevels(totalScoreAtRunStart, params);
const perks = { ...makeEmptyPerksMap(upgrades), ...(params?.perks || {}) }; const perks = { ...makeEmptyPerksMap(upgrades), ...(params?.perks || {}) };
@ -41,7 +40,7 @@ export function newGameState(params: RunParams): GameState {
currentLevel: 0, currentLevel: 0,
upgradesOfferedFor: -1, upgradesOfferedFor: -1,
perks, perks,
bannedPerks:makeEmptyPerksMap(upgrades), bannedPerks: makeEmptyPerksMap(upgrades),
debuffs: { ...emptyDebuffsMap(), ...(params?.debuffs || {}) }, debuffs: { ...emptyDebuffsMap(), ...(params?.debuffs || {}) },
puckWidth: 200, puckWidth: 200,
baseSpeed: 12, baseSpeed: 12,
@ -110,7 +109,8 @@ export function newGameState(params: RunParams): GameState {
autoCleanUses: 0, autoCleanUses: 0,
...defaultSounds(), ...defaultSounds(),
rerolls: 0, rerolls: 0,
loop:0 loop: 0,
baseCombo: 1,
}; };
resetBalls(gameState); resetBalls(gameState);

View file

@ -1,9 +1,9 @@
import {GameState} from "./types"; import { GameState } from "./types";
import {icons} from "./loadGameData"; import { icons } from "./loadGameData";
import {t} from "./i18n/i18n"; import { t } from "./i18n/i18n";
import {getSettingValue, setSettingValue} from "./settings"; import { getSettingValue, setSettingValue } from "./settings";
import {asyncAlert} from "./asyncAlert"; import { asyncAlert } from "./asyncAlert";
import {openMainMenu} from "./game"; import { openMainMenu } from "./game";
const publicKeyString = `-----BEGIN PUBLIC KEY----- const publicKeyString = `-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyGQJgs6gxa0Fd86TuZ2q MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyGQJgs6gxa0Fd86TuZ2q
@ -21,49 +21,49 @@ dZpWPx3O+rZbRQb0gHcvN4+n2Y7fWAeC9mxVZtADqvVr/GTumMbLj7DdhWtt1Ogu
-----END PUBLIC KEY-----`; -----END PUBLIC KEY-----`;
function pemToArrayBuffer(pem: string) { function pemToArrayBuffer(pem: string) {
const b64 = pem const b64 = pem
.replace(/-----BEGIN PUBLIC KEY-----/, "") .replace(/-----BEGIN PUBLIC KEY-----/, "")
.replace(/-----END PUBLIC KEY-----/, "") .replace(/-----END PUBLIC KEY-----/, "")
.replace(/\s+/g, ""); .replace(/\s+/g, "");
const binaryDerString = atob(b64); const binaryDerString = atob(b64);
const binaryDer = new Uint8Array(binaryDerString.length); const binaryDer = new Uint8Array(binaryDerString.length);
for (let i = 0; i < binaryDerString.length; i++) { for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i); binaryDer[i] = binaryDerString.charCodeAt(i);
} }
return binaryDer.buffer; return binaryDer.buffer;
} }
async function getPriceId(key: string, pem: string) { async function getPriceId(key: string, pem: string) {
// Split the key into its components // Split the key into its components
const [priceId, timestamp, signature] = key.split(":"); const [priceId, timestamp, signature] = key.split(":");
const data = `${priceId}:${timestamp}`; const data = `${priceId}:${timestamp}`;
const publicKeyBuffer = pemToArrayBuffer(pem); const publicKeyBuffer = pemToArrayBuffer(pem);
const publicKey = await crypto.subtle.importKey( const publicKey = await crypto.subtle.importKey(
"spki", "spki",
publicKeyBuffer, publicKeyBuffer,
{ {
name: "RSA-PSS", name: "RSA-PSS",
hash: "SHA-256", hash: "SHA-256",
}, },
true, true,
["verify"], ["verify"],
); );
// Verify the signature using ECDSA // Verify the signature using ECDSA
const isValid = await crypto.subtle.verify( const isValid = await crypto.subtle.verify(
{ {
name: "RSA-PSS", name: "RSA-PSS",
saltLength: 32, saltLength: 32,
}, },
publicKey, publicKey,
new Uint8Array(Array.from(atob(signature), (c) => c.charCodeAt(0))), new Uint8Array(Array.from(atob(signature), (c) => c.charCodeAt(0))),
new TextEncoder().encode(data), new TextEncoder().encode(data),
); );
if (!isValid) throw new Error("Invalid key signature"); if (!isValid) throw new Error("Invalid key signature");
return priceId; return priceId;
} }
let premium = false; let premium = false;
@ -71,116 +71,113 @@ const gamePriceId = "price_1R6YaEGRf74lr2EkSo2GPvuO";
checkKey(getSettingValue("license", "")).then(); checkKey(getSettingValue("license", "")).then();
async function checkKey(key: string) { async function checkKey(key: string) {
if (!key) return "No key"; if (!key) return "No key";
try { try {
if (gamePriceId !== (await getPriceId(key, publicKeyString))) { if (gamePriceId !== (await getPriceId(key, publicKeyString))) {
return "Wrong product"; return "Wrong product";
}
premium = true;
return "";
} catch (e) {
return "Could not upgrade : " + e.message;
} }
premium = true;
return "";
} catch (e) {
return "Could not upgrade : " + e.message;
}
} }
export function isPremium() { export function isPremium() {
return premium; return premium;
} }
export function premiumMenuEntry(gameState: GameState) { export function premiumMenuEntry(gameState: GameState) {
if (isPremium()) { if (isPremium()) {
return {
icon: icons["icon:premium_active"],
text: t("premium.thanks"),
help: t("premium.thanks_help"),
value: async () => {
navigator.clipboard.writeText(getSettingValue('license', ''))
openMainMenu()
},
};
}
let text = t("premium.title")
let help = t("premium.buy")
try {
const timePlayed = localStorage.getItem('breakout_71_total_play_time')
if (timePlayed && !isGooglePlayInstall) {
const hours = parseFloat(timePlayed) / 1000 / 60 / 60
const pricePerHours = 4.99 / hours
const args = {
hours: Math.floor(hours),
pricePerHours: pricePerHours.toFixed(2)
}
if (pricePerHours > 0 && pricePerHours < 0.5) {
text = t("premium.per_hours", args)
help = t("premium.per_hours_help", args)
}
console.log({args})
}
} catch (e) {
console.warn(e)
}
return { return {
icon: icons["icon:premium"], icon: icons["icon:premium_active"],
text, text: t("premium.thanks"),
help, help: t("premium.thanks_help"),
value: () => openPremiumMenu(""), value: async () => {
navigator.clipboard.writeText(getSettingValue("license", ""));
openMainMenu();
},
}; };
}
let text = t("premium.title");
let help = t("premium.buy");
try {
const timePlayed = localStorage.getItem("breakout_71_total_play_time");
if (timePlayed && !isGooglePlayInstall) {
const hours = parseFloat(timePlayed) / 1000 / 60 / 60;
const pricePerHours = 4.99 / hours;
const args = {
hours: Math.floor(hours),
pricePerHours: pricePerHours.toFixed(2),
};
if (pricePerHours > 0 && pricePerHours < 0.5) {
text = t("premium.per_hours", args);
help = t("premium.per_hours_help", args);
}
console.log({ args });
}
} catch (e) {
console.warn(e);
}
return {
icon: icons["icon:premium"],
text,
help,
value: () => openPremiumMenu(""),
};
} }
const isGooglePlayInstall = const isGooglePlayInstall =
new URLSearchParams(location.search).get("source") === new URLSearchParams(location.search).get("source") === "com.android.vending";
"com.android.vending";
async function openPremiumMenu(text) { async function openPremiumMenu(text) {
const cb = await asyncAlert({
title: t("premium.title"),
content: [
text ||
(isGooglePlayInstall && t("premium.help_google")) ||
t("premium.help"),
{
text: t("premium.buy"),
disabled: isGooglePlayInstall,
help: isGooglePlayInstall
? t("premium.buy_disabled_help")
: t("premium.buy_help"),
value() {
window.open(
"https://licenses.lecaro.me/buy/price_1R6YaEGRf74lr2EkSo2GPvuO",
"_blank",
);
},
},
{
text: t("premium.enter"),
help: t("premium.enter_help"),
async value() {
const value = (
prompt("Please paste your license key") || ""
)?.replace(/\s+/g, "");
const problem = await checkKey(value || "");
const cb = await asyncAlert({ if (problem) {
title: t("premium.title"), openPremiumMenu(problem).then();
content: [ } else {
text || setSettingValue("license", value);
(isGooglePlayInstall && t("premium.help_google")) || openMainMenu().then();
t("premium.help"), }
{ },
text: t("premium.buy"), },
disabled: isGooglePlayInstall, {
help: isGooglePlayInstall text: t("premium.back"),
? t("premium.buy_disabled_help") help: t("premium.back_help"),
: t("premium.buy_help"), value() {
value() { openMainMenu().then();
window.open( },
"https://licenses.lecaro.me/buy/price_1R6YaEGRf74lr2EkSo2GPvuO", },
"_blank", ],
); });
}, if (cb) cb();
},
{
text: t("premium.enter"),
help: t("premium.enter_help"),
async value() {
const value = (prompt("Please paste your license key") || "").replace(
/\s+/g,
"",
);
const problem = await checkKey(value);
if (problem) {
openPremiumMenu(problem).then();
} else {
setSettingValue("license", value);
openMainMenu().then();
}
},
},
{
text: t("premium.back"),
help: t("premium.back_help"),
value() {
openMainMenu().then();
},
},
],
});
if (cb) cb();
} }

View file

@ -52,9 +52,7 @@ export function drawMainCanvasOnSmallCanvas(gameState: GameState) {
recordCanvasCtx.textAlign = "left"; recordCanvasCtx.textAlign = "left";
recordCanvasCtx.fillText( recordCanvasCtx.fillText(
"Level " + "Level " + (gameState.currentLevel + 1) + "/" + max_levels(gameState),
(gameState.currentLevel + 1) +
"/" + max_levels(gameState),
12, 12,
12, 12,
); );

File diff suppressed because it is too large Load diff

12
src/types.d.ts vendored
View file

@ -83,6 +83,7 @@ export type Coin = {
sa: number; sa: number;
weight: number; weight: number;
destroyed?: boolean; destroyed?: boolean;
collidedLastFrame?: boolean;
coloredABrick?: boolean; coloredABrick?: boolean;
}; };
export type Ball = { export type Ball = {
@ -155,12 +156,12 @@ export type PerksMap = {
[k in PerkId]: number; [k in PerkId]: number;
}; };
type Debuff={ type Debuff = {
id: DebuffId; id: DebuffId;
max:number; max: number;
name:(lvl: number,banned:string)=>string; name: (lvl: number, banned: string) => string;
help:(lvl: number,perk:string)=>string; help: (lvl: number, perk: string) => string;
} };
export type DebuffId = (typeof debuffs)[number]["id"]; export type DebuffId = (typeof debuffs)[number]["id"];
export type DebuffsMap = { export type DebuffsMap = {
@ -287,6 +288,7 @@ export type GameState = {
}; };
rerolls: number; rerolls: number;
loop: number; loop: number;
baseCombo: number;
}; };
export type RunParams = { export type RunParams = {