Balancing

- New perk : addiction, reward faster gameplay
- Balancing : hot start effect doubled
- Balancing : you earn an extra perk when playing well, and a reroll when playing perfectly
- Balancing : telekinesis limited to level 1
This commit is contained in:
Renan LE CARO 2025-03-30 21:07:58 +02:00
parent 27a2cd686e
commit 7e316391d8
22 changed files with 799 additions and 897 deletions

View file

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

View file

@ -1021,13 +1021,6 @@
{
"name": "icon:premium",
"size": 11,
"bricks": "________________y_________yey_________y______yy_yey_yy_yeeyeeeyeeyyeeyeeeyeey_yeyeeeyey___yyyyyyy____yyyyyyy_____________",
"svg": 11,
"color": ""
},
{
"name": "icon:premium_active",
"size": 11,
"bricks": "__y____y___y____y____y_y__yby__y______y______yy_yty_yy_ybbytttybbyybbytttybby_ybytttyby___yyyyyyy____yyyyyyy_____________",
"svg": null,
"color": ""
@ -1045,5 +1038,19 @@
"bricks": "__llllll_llBlBlelllllleBWWWWWeeeWBWBWeBeWWWWWeeeWBWBWBe_WWWWWe__",
"svg": null,
"color": ""
},
{
"name": "icon:loop",
"size": 10,
"bricks": "____yyyy_____yy__yy_________yy__a______y_aaa_____yccccc____y__c_____GG__cc___GG____GGGGG____________",
"svg": null,
"color": ""
},
{
"name": "icon:addiction",
"size": 9,
"bricks": "__________________________l__WWWWW_lWWWyylllly_WWWWW_ly_______l__________________",
"svg": null,
"color": ""
}
]

View file

@ -1 +1 @@
"29054664"
"29056022"

View file

@ -45,7 +45,8 @@ body {
min-width: 40px;
min-height: 40px;
line-height: 20px;
max-width: calc(100vw - 80px);
overflow: hidden;
&:hover,
&:focus {
background: rgba(0, 0, 0, 0.3);
@ -75,6 +76,7 @@ body {
&.good {
color: white;
}
&.bad {
color: white;
}
@ -356,21 +358,55 @@ h2.histogram-title strong {
}
}
//#statsdisplay{
// z-index: 1;
// white-space: nowrap;
// line-height: 20px;
// pointer-events: none;
// user-select: none;
// color: white;
// position: fixed;
// padding: 0;
// bottom: -20px;
// right: 0;
// width: 20px;
// overflow: visible;
//
// transform-origin: top left;
// transform: rotate(-90deg);
//
//}
.upgrade {
display: flex;
gap: 2px;
margin: 0 0 10px 0;
img {
width: 32px;
height: 32px;
}
p {
flex-grow: 1;
color: rgba(255, 255, 255, 0.6);
margin: 0 20px;
}
&.used p strong {
color: white;
}
& > span {
flex-grow: 0;
flex-shrink: 0;
width: 5px;
display: inline-block;
height: 32px;
align-self: center;
&.used {
background: #fff;
}
&.free {
background: #fff;
opacity: 0.1;
}
&.banned {
background: red;
}
}
&.used {
opacity: 1;
}
&.free {
opacity: 0.8;
}
&.banned {
opacity: 0.8;
}
}

View file

@ -34,7 +34,6 @@ import {
import {
forEachLiveOne,
gameStateTick,
liveCount,
normalizeGameState,
pickRandomUpgrades,
setLevel,
@ -63,7 +62,7 @@ import {
} from "./asyncAlert";
import { isOptionOn, options, toggleOption } from "./options";
import { hashCode } from "./getLevelBackground";
import { premiumMenuEntry } from "./premium";
import { hoursSpentPlaying } from "./pure_functions";
export function play() {
if (applyFullScreenChoice()) return;
@ -195,37 +194,37 @@ export async function openUpgradesPicker(gameState: GameState) {
wallHitsGain = "",
missesGain = "";
if (gameState.levelWallBounces == 0) {
if (gameState.levelWallBounces < 3) {
repeats++;
gameState.rerolls++;
wallHitsGain = t("level_up.plus_one_upgrade_and_reroll");
} else if (gameState.levelWallBounces < 10) {
repeats++;
wallHitsGain = t("level_up.plus_one_upgrade");
} else if (gameState.levelWallBounces < 5) {
gameState.rerolls++;
wallHitsGain = t("level_up.plus_one_choice");
}
if (gameState.levelTime < 30 * 1000) {
repeats++;
gameState.rerolls++;
timeGain = t("level_up.plus_one_upgrade");
timeGain = t("level_up.plus_one_upgrade_and_reroll");
} else if (gameState.levelTime < 60 * 1000) {
gameState.rerolls++;
timeGain = t("level_up.plus_one_choice");
repeats++;
timeGain = t("level_up.plus_one_upgrade");
}
if (catchRate === 1) {
if (catchRate > 0.95) {
repeats++;
gameState.rerolls++;
catchGain = t("level_up.plus_one_upgrade");
catchGain = t("level_up.plus_one_upgrade_and_reroll");
} else if (catchRate > 0.9) {
gameState.rerolls++;
catchGain = t("level_up.plus_one_choice");
repeats++;
catchGain = t("level_up.plus_one_upgrade");
}
if (gameState.levelMisses === 0) {
if (gameState.levelMisses < 3) {
repeats++;
gameState.rerolls++;
missesGain = t("level_up.plus_one_upgrade_and_reroll");
} else if (gameState.levelMisses < 6) {
repeats++;
missesGain = t("level_up.plus_one_upgrade");
} else if (gameState.levelMisses <= 3) {
gameState.rerolls++;
missesGain = t("level_up.plus_one_choice");
}
while (repeats--) {
@ -248,17 +247,6 @@ export async function openUpgradesPicker(gameState: GameState) {
icon: icons["icon:reroll"],
});
let textAfterButtons = `
<p>${t("level_up.after_buttons", {
level: gameState.currentLevel + 1,
max: max_levels(gameState),
})} </p>
${pickedUpgradesHTMl(gameState)}
<div id="level-recording-container"></div>
`;
const compliment =
(timeGain &&
catchGain &&
@ -287,11 +275,16 @@ export async function openUpgradesPicker(gameState: GameState) {
compliment,
})}
</p>
<p>${t("level_up.after_buttons", {
level: gameState.currentLevel + 1,
max: max_levels(gameState),
})} </p>
<p>${levelsListHTMl(gameState)}</p>
`,
...actions,
textAfterButtons,
pickedUpgradesHTMl(gameState),
`<div id="level-recording-container"></div>`,
],
});
@ -435,11 +428,6 @@ document.addEventListener("visibilitychange", () => {
async function openScorePanel() {
pause(true);
const banned = upgrades
.filter((u) => gameState.bannedPerks[u.id])
.map((u) => u.name)
.join(", ");
const cb = await asyncAlert({
title: gameState.loop
? t("score_panel.title_looped", {
@ -461,7 +449,6 @@ async function openScorePanel() {
gameState.rerolls
? t("score_panel.rerolls_count", { rerolls: gameState.rerolls })
: "",
banned && t("score_panel.banned", { banned }),
],
allowClose: true,
});
@ -487,7 +474,7 @@ export async function openMainMenu() {
text: t("main_menu.normal"),
help: t("main_menu.normal_help"),
value: () => {
restart({ levelToAvoid: currentLevelInfo(gameState).name });
restart({ levelToAvoid: currentLevelInfo(gameState).name, maxLoop: 0 });
},
},
{
@ -498,6 +485,19 @@ export async function openMainMenu() {
openUnlocksList();
},
},
{
icon: icons["icon:loop"],
text: t("main_menu.loop_run"),
help:
getTotalScore() < creativeModeThreshold
? t("sandbox.unlocks_at", { score: creativeModeThreshold })
: t("main_menu.loop_run_help"),
value: () => {
restart({ levelToAvoid: currentLevelInfo(gameState).name, maxLoop: 7 });
},
disabled: getTotalScore() < creativeModeThreshold,
},
{
icon: icons["icon:sandbox"],
text: t("sandbox.title"),
@ -546,7 +546,7 @@ export async function openMainMenu() {
},
},
premiumMenuEntry(gameState),
...donationNag(gameState),
{
text: t("main_menu.settings_title"),
help: t("main_menu.settings_help"),
@ -568,6 +568,22 @@ export async function openMainMenu() {
}
}
function donationNag(gameState) {
if (!isOptionOn("donation_reminder")) return [];
const hours = hoursSpentPlaying();
return [
{
text: t("main_menu.donate", { hours }),
help: t("main_menu.donate_help", {
suggestion: Math.min(20, Math.max(1, 0.2 * hours)).toFixed(0),
}),
icon: icons["icon:premium"],
value() {
window.open("https://paypal.me/renanlecaro", "_blank");
},
},
];
}
async function openSettingsMenu() {
pause(true);
@ -842,8 +858,6 @@ async function openUnlocksList() {
.sort((a, b) => a.threshold - b.threshold)
.map(({ name, id, threshold, icon, help }) => ({
text: name,
// help:
// ts >= threshold ? help(1) : t("unlocks.unlocks_at", { threshold }),
disabled: ts < threshold,
value: { perks: { [id]: 1 } } as RunParams,
icon,
@ -855,12 +869,6 @@ async function openUnlocksList() {
const available = ts >= l.threshold;
return {
text: l.name,
// help: available
// ? t("unlocks.level_description", {
// size: l.size,
// bricks: l.bricks.filter((i) => i).length,
// })
// : t("unlocks.unlocks_at", { threshold: l.threshold }),
disabled: !available,
value: { level: l.name } as RunParams,
icon: icons[l.name],
@ -886,13 +894,14 @@ async function openUnlocksList() {
});
if (tryOn) {
if (await confirmRestart(gameState)) {
restart(tryOn);
restart({ ...tryOn, maxLoop: 0 });
}
}
}
export async function confirmRestart(gameState) {
if (!gameState.currentLevel) return true;
if (alertsOpen) return true;
return asyncAlert({
title: t("confirmRestart.title"),
@ -925,7 +934,7 @@ export function setKeyPressed(key: string, on: 0 | 1) {
50;
}
document.addEventListener("keydown", (e) => {
document.addEventListener("keydown", async (e) => {
if (e.key.toLowerCase() === "f" && !e.ctrlKey && !e.metaKey) {
toggleOption("fullscreen");
applyFullScreenChoice();
@ -944,6 +953,7 @@ document.addEventListener("keydown", (e) => {
e.preventDefault();
});
let pageLoad = new Date();
document.addEventListener("keyup", async (e) => {
const focused = document.querySelector("button:focus");
if (e.key in pressed) {
@ -966,7 +976,12 @@ document.addEventListener("keyup", async (e) => {
openMainMenu().then();
} else if (e.key.toLowerCase() === "s" && !alertsOpen) {
openScorePanel().then();
} else if (e.key.toLowerCase() === "r" && !alertsOpen) {
} else if (
e.key.toLowerCase() === "r" &&
!alertsOpen &&
pageLoad > Date.now() + 1000
) {
// When doing ctrl + R in dev to refresh, i don't want to instantly restart a run
if (await confirmRestart(gameState)) {
restart({ levelToAvoid: currentLevelInfo(gameState).name });
}
@ -979,6 +994,7 @@ document.addEventListener("keyup", async (e) => {
export const gameState = newGameState({});
export function restart(params: RunParams) {
console.log("restart : ", params);
fitSize();
Object.assign(gameState, newGameState(params));
pauseRecording();
@ -987,18 +1003,20 @@ export function restart(params: RunParams) {
restart(
(window.location.search.includes("stressTest") && {
level: "Bird",
// level: "Bird",
perks: {
shocks: 10,
multiball: 6,
telekinesis: 2,
ghost_coins: 1,
pierce: 4,
clairvoyant: 3,
bigger_explosions: 2,
sapper: 2,
unbounded: 1,
// shocks: 10,
// multiball: 6,
// telekinesis: 2,
// ghost_coins: 1,
pierce: 2,
// clairvoyant: 2,
// sturdy_bricks:2,
bigger_explosions: 10,
sapper: 3,
// unbounded: 1,
},
levelsPerLoop: 2,
}) ||
{},

View file

@ -49,7 +49,6 @@ import {
} from "./game";
import { stopRecording } from "./recording";
import { isOptionOn } from "./options";
import { isPremium } from "./premium";
import { getRunLevels } from "./newGameState";
import { requiredAsyncAlert } from "./asyncAlert";
import { clamp, comboKeepingRate } from "./pure_functions";
@ -343,6 +342,8 @@ export function explodeBrick(
const color = gameState.bricks[index];
if (!color) return;
gameState.lastBrickBroken = gameState.levelTime;
if (color === "black") {
const x = brickCenterX(gameState, index),
y = brickCenterY(gameState, index);
@ -366,7 +367,7 @@ export function explodeBrick(
if (gameState.perks.sturdy_bricks) {
// +10% per level
coinsToSpawn += Math.ceil(
((10 + gameState.perks.sturdy_bricks) / 10) * coinsToSpawn,
((2 + gameState.perks.sturdy_bricks) / 2) * coinsToSpawn,
);
}
@ -420,6 +421,7 @@ export function explodeBrick(
gameState.perks.zen +
gameState.perks.passive_income +
gameState.perks.nbricks +
gameState.perks.addiction +
gameState.perks.unbounded;
if (gameState.perks.side_kick) {
@ -511,8 +513,7 @@ export function pickRandomUpgrades(gameState: GameState, count: number) {
score: Math.random() + (gameState.lastOffered[u.id] || 0),
}))
.sort((a, b) => a.score - b.score)
.filter((u) => gameState.perks[u.id] < u.max)
.filter((u) => !gameState.bannedPerks[u.id])
.filter((u) => gameState.perks[u.id] < u.max - gameState.bannedPerks[u.id])
.slice(0, count)
.sort((a, b) => (a.id > b.id ? 1 : -1));
@ -616,15 +617,19 @@ export async function gotoNextLoop(gameState: GameState) {
}),
],
});
// Decrease max level of all perks
userPerks.forEach((u) => {
if (u.id !== keep) {
gameState.bannedPerks[u.id] = 1;
gameState.bannedPerks[u.id] += gameState.perks[u.id];
}
});
// Increase max level of kept perk by 2
gameState.bannedPerks[keep] -= 2;
// Increase current level of kept perk by 1
Object.assign(gameState.perks, makeEmptyPerksMap(upgrades), {
[keep]: gameState.perks[keep],
[keep]: gameState.perks[keep] + 1,
});
await setLevel(gameState, 0);
@ -655,6 +660,7 @@ export async function setLevel(gameState: GameState, l: number) {
gameState.levelSpawnedCoins = 0;
gameState.levelLostCoins = 0;
gameState.levelMisses = 0;
gameState.lastBrickBroken = 0;
gameState.runStatistics.levelsPlayed++;
// Reset combo silently
@ -669,7 +675,8 @@ export async function setLevel(gameState: GameState, l: number) {
),
);
}
gameState.combo += gameState.perks.hot_start * 15;
gameState.combo += gameState.perks.hot_start * 30;
const lvl = currentLevelInfo(gameState);
if (lvl.size !== gameState.gridSize) {
@ -923,6 +930,19 @@ export function gameStateTick(
gameState.combo,
);
if (
gameState.perks.addiction &&
gameState.lastBrickBroken &&
gameState.lastBrickBroken <
gameState.levelTime - 5000 / gameState.perks.addiction
) {
resetCombo(
gameState,
gameState.puckPosition,
gameState.gameZoneHeight - gameState.puckHeight * 2,
);
}
gameState.balls = gameState.balls.filter((ball) => !ball.destroyed);
const remainingBricks = gameState.bricks.filter(
@ -974,15 +994,13 @@ export function gameStateTick(
) {
if (gameState.currentLevel + 1 < max_levels(gameState)) {
setLevel(gameState, gameState.currentLevel + 1);
} else if (gameState.loop < gameState.maxLoop) {
gotoNextLoop(gameState);
} else {
if (isPremium()) {
gotoNextLoop(gameState);
} else {
gameOver(
t("gameOver.win.title"),
t("gameOver.win.summary", { score: gameState.score }),
);
}
gameOver(
t("gameOver.7_loop.title", { loop: gameState.loop }),
t("gameOver.7_loop.summary", { score: gameState.score }),
);
}
} else if (gameState.running || gameState.levelTime) {
const coinRadius = Math.round(gameState.coinSize / 2);
@ -1558,6 +1576,7 @@ export function ballTick(gameState: GameState, ball: Ball, delta: number) {
const hitBrick = vhit ?? hhit ?? chit;
if (typeof hitBrick !== "undefined") {
const initialBrickColor = gameState.bricks[hitBrick];
ball.hitSinceBounce++;
let pierce = false;
let damage =
@ -1570,7 +1589,7 @@ export function ballTick(gameState: GameState, ball: Ball, delta: number) {
const used = Math.min(
ball.piercePoints,
Math.max(1, gameState.brickHP[hitBrick]),
Math.max(1, gameState.brickHP[hitBrick] + 1),
);
gameState.brickHP[hitBrick] -= used;
ball.piercePoints -= used;
@ -1592,8 +1611,13 @@ export function ballTick(gameState: GameState, ball: Ball, delta: number) {
}
}
console.log("After bounce", {
pierce,
initialBrickColor,
hitBrick,
hp: gameState.brickHP[hitBrick],
});
if (!gameState.brickHP[hitBrick]) {
const initialBrickColor = gameState.bricks[hitBrick];
ball.brokenSinceBounce++;
explodeBrick(gameState, hitBrick, ball, false);
@ -1602,6 +1626,7 @@ export function ballTick(gameState: GameState, ball: Ball, delta: number) {
initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks
!gameState.bricks[hitBrick]
) {
console.log("sapper use", hitBrick);
setBrick(gameState, hitBrick, "black");
ball.sapperUses++;
}

View file

@ -53,26 +53,52 @@ export function getRowColIndex(gameState: GameState, row: number, col: number) {
export function getPossibleUpgrades(gameState: GameState) {
return upgrades
.filter(
(u) =>
gameState.totalScoreAtRunStart >= u.threshold || gameState.loop > 0,
)
.filter((u) => gameState.totalScoreAtRunStart >= u.threshold)
.filter((u) => !u?.requires || gameState.perks[u?.requires]);
}
export function max_levels(gameState: GameState) {
return gameState.levelsPerLoop + gameState.perks.extra_levels;
return Math.max(
gameState.levelsPerLoop + gameState.perks.extra_levels - gameState.loop,
1,
);
}
export function pickedUpgradesHTMl(gameState: GameState) {
let list = "";
for (let u of upgrades) {
for (let i = 0; i < gameState.perks[u.id]; i++)
list += `<span title="${u.name} : ${u.help(gameState.perks[u.id])}">${icons["icon:" + u.id]}</span>`;
}
const upgradesList = getPossibleUpgrades(gameState)
.map((u) => {
const newMax = Math.max(0, u.max - gameState.bannedPerks[u.id]);
if (!list) return "";
return ` <p>${t("score_panel.upgrades_picked")}</p> <p>${list}</p>`;
let bars = "";
for (let i = 0; i < Math.max(u.max, newMax, gameState.perks[u.id]); i++) {
if (i < gameState.perks[u.id]) {
bars += '<span class="used"></span>';
} else if (i < newMax) {
bars += '<span class="free"></span>';
} else {
bars += '<span class="banned"></span>';
}
}
const state = (!newMax && 2) || (!gameState.perks[u.id] && 1) || 0;
return {
state,
html: `
<div class="upgrade ${["used", "free", "banned"][state]}">
${u.icon}
<p>
<strong>${u.name}</strong>
${u.help(Math.max(1, gameState.perks[u.id]))}
</p>
${bars}
</div>
`,
};
})
.sort((a, b) => a.state - b.state)
.map((a) => a.html);
return ` <p>${t("score_panel.upgrades_picked")}</p>` + upgradesList.join("");
}
export function levelsListHTMl(gameState: GameState) {
@ -131,8 +157,6 @@ export function defaultSounds() {
lifeLost: { vol: 0, x: 0 },
coinCatch: { vol: 0, x: 0 },
colorChange: { vol: 0, x: 0 },
void: { vol: 0, x: 0 },
freeze: { vol: 0, x: 0 },
},
};
}

View file

@ -87,6 +87,41 @@
<folder_node>
<name>gameOver</name>
<children>
<folder_node>
<name>7_loop</name>
<children>
<concept_node>
<name>summary</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>title</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<concept_node>
<name>cumulative_total</name>
<description/>
@ -543,7 +578,7 @@
</translations>
</concept_node>
<concept_node>
<name>plus_one_choice</name>
<name>plus_one_upgrade</name>
<description/>
<comment/>
<translations>
@ -558,7 +593,7 @@
</translations>
</concept_node>
<concept_node>
<name>plus_one_upgrade</name>
<name>plus_one_upgrade_and_reroll</name>
<description/>
<comment/>
<translations>
@ -777,6 +812,66 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>donate</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>
<name>donate_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>
<concept_node>
<name>donation_reminder</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>
<name>donation_reminder_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>
<concept_node>
<name>download_save_file</name>
<description/>
@ -942,6 +1037,36 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>loop_run</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>
<name>loop_run_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>
<concept_node>
<name>max_coins</name>
<description/>
@ -1564,221 +1689,6 @@
</concept_node>
</children>
</folder_node>
<folder_node>
<name>premium</name>
<children>
<concept_node>
<name>back</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>back_help</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>buy</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>buy_disabled_help</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>buy_help</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>enter</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>enter_help</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>help</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>help_google</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>per_hours</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>
<name>per_hours_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>
<concept_node>
<name>thanks</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>
<name>thanks_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>
<concept_node>
<name>title</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>
<folder_node>
<name>sandbox</name>
<children>
@ -1862,21 +1772,6 @@
<folder_node>
<name>score_panel</name>
<children>
<concept_node>
<name>banned</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>
<name>rerolls_count</name>
<description/>
@ -2067,6 +1962,56 @@
<folder_node>
<name>upgrades</name>
<children>
<folder_node>
<name>addiction</name>
<children>
<concept_node>
<name>fullHelp</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>help</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>name</name>
<description/>
<comment/>
<translations>
<translation>
<language>en-US</language>
<approved>true</approved>
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<folder_node>
<name>asceticism</name>
<children>

View file

@ -3,6 +3,8 @@
"confirmRestart.text": "You're about to start a new run, is that really what you wanted ?",
"confirmRestart.title": "Start a new run ?",
"confirmRestart.yes": "Restart game",
"gameOver.7_loop.summary": "This run is over. You stashed {{score}} coins. ",
"gameOver.7_loop.title": "You completed this run",
"gameOver.cumulative_total": "Your total cumulative score went from {{startTs}} to {{endTs}}.",
"gameOver.lost.summary": "You dropped the ball after catching {{score}} coins.",
"gameOver.lost.title": "Game Over",
@ -26,27 +28,31 @@
"gameOver.upgrades_picked": "Upgrades active at the end of the run",
"gameOver.win.summary": "You cleared all levels for this run, catching {{score}} coins in total.",
"gameOver.win.title": "Run finished",
"level_up.after_buttons": "You just finished level {{level}}/{{max}} and picked those upgrades so far :",
"level_up.after_buttons": "You just finished level {{level}}/{{max}}.",
"level_up.before_buttons": "You caught {{score}} coins {{catchGain}} out of {{levelSpawnedCoins}} in {{time}} seconds {{timeGain}}.\n\nYou missed {{levelMisses}} times {{missesGain}} and hit the walls or ceiling {{levelWallBounces}} times{{wallHitsGain}}.\n\n{{compliment}}",
"level_up.compliment_advice": "Try to catch all coins, never miss the bricks, never hit the walls/ceiling or clear the level under 30s to gain additional choices and upgrades.",
"level_up.compliment_good": "Well done !",
"level_up.compliment_perfect": "Impressive, keep it up !",
"level_up.pick_upgrade_title": "Pick an upgrade",
"level_up.plus_one_choice": "(+1 re-roll)",
"level_up.plus_one_upgrade": "(+1 upgrade and +1 re-roll)",
"level_up.plus_one_upgrade": "(+1 upgrade)",
"level_up.plus_one_upgrade_and_reroll": "(+1 upgrade and +1 re-roll)",
"level_up.reroll": "Re-roll ({{count}})",
"level_up.reroll_help": "Offer new choices",
"level_up.unlocked_level": " (Level)",
"level_up.unlocked_perk": " (Perk)",
"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 perks you have now will be banned for the rest of the run, except the one that you pick below. Your pick will be leveled up, even beyond the maximum normally allowed for that perk.",
"loop.instructions": "All perks you have now will have lower max levels depending on their current level. You can pick one below that will gain one level and one max level instead. Your pick will be leveled up, even beyond the maximum normally allowed for that perk.",
"loop.no_rerolls": "You didn't have any leftover re-rolls, so your base combo stayed the same. ",
"loop.title": "Starting loop {{loop}}",
"main_menu.basic": "Basic graphics",
"main_menu.basic_help": "Better performance.",
"main_menu.colorful_coins": "Colorful coins",
"main_menu.colorful_coins_help": "Coins always spawn of the color of the brick",
"main_menu.donate": "You've played for {{hours}} hours",
"main_menu.donate_help": "How about donating {{suggestion}} € ? You can hide this reminder in the settings. ",
"main_menu.donation_reminder": "Remind me to donate",
"main_menu.donation_reminder_help": "See time played and donation link in main menu",
"main_menu.download_save_file": "Download score and stats",
"main_menu.download_save_file_help": "Get a save file",
"main_menu.footer_html": "<p> \n<span>Made in France by <a href=\"https://lecaro.me\">Renan LE CARO</a>.</span> \n<a href=\"https://paypal.me/renanlecaro\" target=\"_blank\">Donate</a>\n<a href=\"https://discord.gg/DZSPqyJkwP\" target=\"_blank\">Discord</a>\n<a href=\"https://f-droid.org/en/packages/me.lecaro.breakout/\" target=\"_blank\">F-Droid</a>\n<a href=\"https://play.google.com/store/apps/details?id=me.lecaro.breakout\" target=\"_blank\">Google Play</a>\n<a href=\"https://renanlecaro.itch.io/breakout71\" target=\"_blank\">itch.io</a> \n<a href=\"https://gitlab.com/lecarore/breakout71\" target=\"_blank\">Gitlab</a>\n<a href=\"https://breakout.lecaro.me/\" target=\"_blank\">Web version</a>\n<a href=\"https://news.ycombinator.com/item?id=43183131\" target=\"_blank\">HackerNews</a>\n<a href=\"https://breakout.lecaro.me/privacy.html\" target=\"_blank\">Privacy Policy</a>\n<span>v.{{appVersion}}</span>\n</p>\n",
@ -58,13 +64,15 @@
"main_menu.language_help": "Choose the game's language",
"main_menu.load_save_file": "Load save file",
"main_menu.load_save_file_help": "Select a save file on your device",
"main_menu.loop_run": "New long run",
"main_menu.loop_run_help": "Allows you to loop up to 7 times",
"main_menu.max_coins": " {{max}} coins on screen maximum",
"main_menu.max_coins_help": "Cosmetic only, no effect on score",
"main_menu.max_particles": " {{max}} particles maximum",
"main_menu.max_particles_help": "Limits the number of particles show on screen for visual effect. ",
"main_menu.mobile": "Mobile mode",
"main_menu.mobile_help": "Leaves space under the puck.",
"main_menu.normal": "New run",
"main_menu.normal": "New short run",
"main_menu.normal_help": "Start a quick run with random starting perk",
"main_menu.pointer_lock": "Mouse pointer lock",
"main_menu.pointer_lock_help": "Locks and hides the mouse cursor.",
@ -74,7 +82,7 @@
"main_menu.reset": "Reset Game",
"main_menu.reset_cancel": "No",
"main_menu.reset_confirm": "Yes",
"main_menu.reset_help": "Erase high score, license and statistics",
"main_menu.reset_help": "Erase high score, play time and statistics",
"main_menu.reset_instruction": "You will loose all progress you made in the game, are you sure ?",
"main_menu.resume": "Resume",
"main_menu.resume_help": "Return to your run",
@ -99,26 +107,11 @@
"play.menu_label": "menu",
"play.missed_ball": "miss",
"play.mobile_press_to_play": "Press and hold here to play",
"premium.back": "Back",
"premium.back_help": "Return to main menu",
"premium.buy": "Buy a license key",
"premium.buy_disabled_help": "Coming soon",
"premium.buy_help": "You'll be taken to a stripe form to pay and will receive the license by email. Come back to enter it here after.",
"premium.enter": "Enter license key",
"premium.enter_help": "Paste the license in the window that opens",
"premium.help": "Buy a license for Breakout 71 to unlock looping and support development. It costs 4.99€ and lasts forever. You can use it on multiple devices, but please don't share it online. ",
"premium.help_google": "While I do plan to offer premium licenses through google play, I haven't gotten around it yet, so there's no buy link here. If you already have a license key, you can enter it below. ",
"premium.per_hours": "You've played for {{hours}} hours",
"premium.per_hours_help": "Donate 4.99€ to get premium",
"premium.thanks": "You are premium, thanks ! ",
"premium.thanks_help": "Copy your license key",
"premium.title": "Unlock looping with premium ",
"sandbox.help": "Test any perk combination",
"sandbox.instructions": "Select perks below and press \"start run\" to try them out in a test run. Scores and stats are not recorded.",
"sandbox.start": "Start test run",
"sandbox.title": "Sandbox mode",
"sandbox.unlocks_at": "Unlocks at total score {{score}}",
"score_panel.banned": "Banned perks : {{banned}}",
"score_panel.rerolls_count": "You have accumulated {{rerolls}} rerolls",
"score_panel.test_run": "This is a test run, score is not recorded permanently",
"score_panel.title": "{{score}} points at level {{level}}/{{max}} ",
@ -126,11 +119,14 @@
"score_panel.upcoming_levels": "Upcoming levels :",
"score_panel.upgrades_picked": "Upgrades picked so far : ",
"unlocks.greyed_out_help": "The greyed out ones can be unlocked by increasing your total score. The total score increases every time you score in game.",
"unlocks.intro": "Your total score is {{ts}}. Below are all the upgrades and levels the games has to offer. Click an upgrade or level below to start a run with it .Your high score is {{highScore}}.",
"unlocks.intro": "Your total score is {{ts}}. Below are all the upgrades and levels the games has to offer. Click an upgrade or level below to start a short run with it .Your high score is {{highScore}}.",
"unlocks.level": "Here are all the game levels, click one to start a run with that starting level. ",
"unlocks.level_description": "A {{size}}x{{size}} level with {{bricks}} bricks",
"unlocks.title": "You unlocked {{percentUnlock}}% of the game. ",
"unlocks.unlocks_at": "Unlocks at total score {{threshold}}.",
"upgrades.addiction.fullHelp": "The countdown only starts after breaking the first brick of each level",
"upgrades.addiction.help": "+{{lvl}} combo / brick, combo resets {{delay}}s after breaking a brick. ",
"upgrades.addiction.name": "Addiction",
"upgrades.asceticism.fullHelp": "You'll need to store the coins somewhere while your combo climbs. ",
"upgrades.asceticism.help": "+{{combo}} combo / brick, combo resets on coin catch",
"upgrades.asceticism.name": "Asceticism",
@ -190,7 +186,7 @@
"upgrades.helium.help": "Gravity reversed left and right of puck",
"upgrades.helium.name": "Helium",
"upgrades.hot_start.fullHelp": "At the start of every level, your combo will start at +15 points, but then every second it will be decreased by one.\n\nThis means the first 15 seconds in a level will spawn many more coins than the following ones, and you should make sure that you clear the level quickly. \n\nThe effect stacks with other combo related perks, so you might be able to raise the combo after the 15s timeout, but it will keep ticking down. \n\nEvery time you take the perk again, the effect will be more dramatic.",
"upgrades.hot_start.help": "Start at combo {{start}}, -{{lvl}} combo per second",
"upgrades.hot_start.help": "Start at combo {{start}}, -{{loss}} combo per second",
"upgrades.hot_start.name": "Hot start",
"upgrades.implosions.fullHelp": "The explosion force is applied the other way. ",
"upgrades.implosions.help": "Explosions suck coins in instead of blowing them out",
@ -233,7 +229,7 @@
"upgrades.reach.help": "+{{lvl}} combo / bricks , lowest brick of a pile resets combo",
"upgrades.reach.name": "Top down",
"upgrades.respawn.fullHelp": "Some particle effect will let you know where bricks will appear. ",
"upgrades.respawn.help": "{{percent}} of bricks re-spawn after {{delay}}s.",
"upgrades.respawn.help": "{{percent}}% of bricks re-spawn after {{delay}}s.",
"upgrades.respawn.name": "Re-spawn",
"upgrades.right_is_lava.fullHelp": "Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break.\n\nHowever, your combo will reset as soon as your ball hits the right side . \n\nAs soon as your combo rises, the right side becomes red to remind you that you should avoid hitting them\n\nThe effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any\nof the reset conditions are met.",
"upgrades.right_is_lava.help": "+{{lvl}} combo per brick broken, resets on right side hit",

View file

@ -3,11 +3,13 @@
"confirmRestart.text": "Vous êtes sur le point de commencer une nouvelle partie, est-ce vraiment ce que vous vouliez ?",
"confirmRestart.title": "Démarrer une nouvelle partie ?",
"confirmRestart.yes": "Commencer une nouvelle partie",
"gameOver.7_loop.summary": "Cette partie est terminée. Vous avez accumulé {{score}} pièces.",
"gameOver.7_loop.title": "Vous avez terminé cette partie",
"gameOver.cumulative_total": "Votre score total cumulé est passé de {{startTs}} à {{endTs}}.",
"gameOver.lost.summary": "Vous avez fait tomber la balle après avoir attrapé {{score}} pièces.",
"gameOver.lost.title": "Balle perdue",
"gameOver.next_unlock": "Marquez {{points}} points supplémentaires pour débloquer la prochaine amélioration ou le prochain niveau.",
"gameOver.restart": "Nouvelle partie",
"gameOver.restart": "Nouvelle partie ",
"gameOver.stats.balls_lost": "Balles perdues",
"gameOver.stats.bricks_broken": "Briques cassées",
"gameOver.stats.bricks_per_minute": "Briques cassées par minute",
@ -26,14 +28,14 @@
"gameOver.upgrades_picked": "Amélioration actives en fin de partie",
"gameOver.win.summary": "Vous avez nettoyé tous les niveaux pour cette partie, en attrapant {{score}} pièces au total.",
"gameOver.win.title": "Partie terminée",
"level_up.after_buttons": "Vous venez de terminer le niveau {{level}}/{{max}} et vous avez choisi ces améliorations jusqu'à présent :",
"level_up.after_buttons": "Vous venez de terminer le niveau {{level}}/{{max}}.",
"level_up.before_buttons": "Vous avez attrapé {{score}} pièces {{catchGain}} sur {{levelSpawnedCoins}} en {{time}} secondes {{timeGain}}.\n\nVous avez raté les briques {{levelMisses}} fois {{missesGain}} et touché les bords de la zone de jeu {{levelWallBounces}} fois {{wallHitsGain}}.\n\n{{compliment}}",
"level_up.compliment_advice": "Essayez d'attraper toutes les pièces, de ne jamais rater les briques, de ne pas toucher les murs ou de terminer le niveau en moins de 30 secondes pour obtenir des choix supplémentaires et des améliorations.",
"level_up.compliment_good": "Bravo !",
"level_up.compliment_perfect": "Impressionnant, continuez comme ça !",
"level_up.pick_upgrade_title": "Choisir une amélioration",
"level_up.plus_one_choice": "(+1 re-roll)",
"level_up.plus_one_upgrade": "(+1 amélioration et +1 re-roll)",
"level_up.plus_one_upgrade": "(+1 upgrade)",
"level_up.plus_one_upgrade_and_reroll": "(+1 amélioration et +1 re-roll)",
"level_up.reroll": "Re-roll ({{count}})",
"level_up.reroll_help": "Nouveaux choix",
"level_up.unlocked_level": " (Niveau)",
@ -47,6 +49,10 @@
"main_menu.basic_help": "Meilleures performances.",
"main_menu.colorful_coins": "Pièces colorées",
"main_menu.colorful_coins_help": "Les pièces apparaissent toujours de la couleur de la brique",
"main_menu.donate": "Vous avez joué {{hours}} heures",
"main_menu.donate_help": "Pourriez-vous donner {{suggestion}} € ? Vous pouvez masquer ce rappel dans les paramètres.",
"main_menu.donation_reminder": "Me rappeler de donner",
"main_menu.donation_reminder_help": "Afficher le temps de jeu et un lien pour donner dans le menu principal",
"main_menu.download_save_file": "Sauvegarder mes progrès",
"main_menu.download_save_file_help": "Obtenir un fichier de sauvegarde",
"main_menu.footer_html": " <p> \n<span>Programmé en France par <a href=\"https://lecaro.me\">Renan LE CARO</a>.</span>\n<a href=\"https://paypal.me/renanlecaro\" target=\"_blank\">Donner</a>\n<a href=\"https://discord.gg/DZSPqyJkwP\" target=\"_blank\">Discord</a>\n<a href=\"https://f-droid.org/en/packages/me.lecaro.breakout/\" target=\"_blank\">F-Droid</a>\n<a href=\"https://play.google.com/store/apps/details?id=me.lecaro.breakout\" target=\"_blank\">Google Play</a>\n<a href=\"https://renanlecaro.itch.io/breakout71\" target=\"_blank\">itch.io</a>\n<a href=\"https://gitlab.com/lecarore/breakout71\" target=\"_blank\">Gitlab</a>\n<a href=\"https://breakout.lecaro.me/\" target=\"_blank\">Version web</a>\n<a href=\"https://news.ycombinator.com/item?id=43183131\" target=\"_blank\">HackerNews</a>\n<a href=\"https://breakout.lecaro.me/privacy.html\" target=\"_blank\">Politique de confidentialité</a> \n<span>v.{{appVersion}}</span>\n</p>",
@ -58,13 +64,15 @@
"main_menu.language_help": "Changer la langue d'affichage",
"main_menu.load_save_file": "Charger une sauvegarde",
"main_menu.load_save_file_help": "Depuis un fichier ",
"main_menu.loop_run": "Nouvelle partie longue",
"main_menu.loop_run_help": "Permet de boucler le jeu jusqu'à 7 fois",
"main_menu.max_coins": "{{max}} pièces affichées maximum",
"main_menu.max_coins_help": "Visuel uniquement, pas d'impact sur le score",
"main_menu.max_particles": " {{max}} particules maximum",
"main_menu.max_particles_help": "Limite le nombre de particules affichées à l'écran pour les effets visuels",
"main_menu.mobile": "Mode mobile",
"main_menu.mobile_help": "Laisse un espace sous le palet.",
"main_menu.normal": "Nouvelle partie",
"main_menu.normal": "Nouvelle partie rapide",
"main_menu.normal_help": "Avec un avantage de départ aléatoire",
"main_menu.pointer_lock": "Verrouillage du pointeur",
"main_menu.pointer_lock_help": "Cache aussi le curseur de la souris.",
@ -74,7 +82,7 @@
"main_menu.reset": "Réinitialiser le jeu",
"main_menu.reset_cancel": "Non",
"main_menu.reset_confirm": "Oui",
"main_menu.reset_help": "Effacer les scores, statistiques et licences",
"main_menu.reset_help": "Effacer les scores, statistiques et temps de jeu",
"main_menu.reset_instruction": "Vous perdrez tous les progrès que vous avez faits dans le jeu, êtes-vous sûr ?",
"main_menu.resume": "Retourner à la partie",
"main_menu.resume_help": "Continuer la partie en cours",
@ -85,8 +93,8 @@
"main_menu.settings_help": "Adaptez le jeu à vos besoins",
"main_menu.settings_title": "Paramètre",
"main_menu.show_fps": "Compteur de FPS",
"main_menu.show_fps_help": "Surveiller la perf du jeu",
"main_menu.show_stats": "Afficher les statistiques en temps réel",
"main_menu.show_fps_help": "Surveiller la performance du jeu",
"main_menu.show_stats": "Statistiques en temps réel",
"main_menu.show_stats_help": "Pièces, temps, rebonds, ratés",
"main_menu.sounds": "Sons du jeu",
"main_menu.sounds_help": "Ralentis certains téléphones.",
@ -99,26 +107,11 @@
"play.menu_label": "Menu",
"play.missed_ball": "raté",
"play.mobile_press_to_play": "Gardez le doigt ici pour jouer",
"premium.back": "Retour",
"premium.back_help": "Retour au menu principal",
"premium.buy": "Acheter une clé de licence",
"premium.buy_disabled_help": "À venir",
"premium.buy_help": "Vous serez redirigé vers un formulaire pour payer et recevrez la licence par e-mail. Revenez ensuite pour la saisir ici.",
"premium.enter": "Entrez la clé de licence",
"premium.enter_help": "Collez la licence dans la fenêtre qui s'ouvre",
"premium.help": "Achetez une licence pour Breakout 71 pour débloquer le bouclage du jeu et soutenir le développement. Elle coûte 4,99 € et est illimitée dans le temps. Vous pouvez l'utiliser sur plusieurs appareils, mais ne la partagez pas en ligne.",
"premium.help_google": "Bien que je prévoie de proposer des licences premium via Google Play, je n'ai pas encore eu l'occasion de le faire ; il n'y a donc pas de lien d'achat ici. Si vous possédez déjà une clé de licence, vous pouvez la saisir ci-dessous.",
"premium.per_hours": "Vous avez passé {{hours}} heures à jouer",
"premium.per_hours_help": "Donnez 4.99€ pour être premium",
"premium.thanks": "Vous êtes premium, merci !",
"premium.thanks_help": "Copiez votre clé de licence",
"premium.title": "Débloquez la boucle avec Premium",
"sandbox.help": "Tester n'importe quelle combinaison d'améliorations",
"sandbox.instructions": "Sélectionnez les amélioration ci-dessous et appuyez sur \"Démarrer la partie de test\" pour les tester. Les scores et les statistiques ne seront pas enregistrés.",
"sandbox.start": "Démarrer la partie de test",
"sandbox.title": "Mode bac à sable",
"sandbox.unlocks_at": "Déverrouillé à partir d'un score total de {{score}}",
"score_panel.banned": "Améliorations bannies : {{banned}}",
"score_panel.rerolls_count": "Vous avez accumulé {{rerolls}} rerolls",
"score_panel.test_run": "Il s'agit d'une partie d'essai, le score n'est pas enregistré.",
"score_panel.title": "{{score}} points au niveau {{level}}/{{max}} ",
@ -126,11 +119,14 @@
"score_panel.upcoming_levels": "Niveaux de la parties : ",
"score_panel.upgrades_picked": "Améliorations choisies jusqu'à présent :",
"unlocks.greyed_out_help": "Les éléments grisées peuvent être débloquées en augmentant votre score total. Le score total augmente à chaque fois que vous marquez des points dans le jeu.",
"unlocks.intro": "Votre score total est de {{ts}}. Vous trouverez ci-dessous toutes les améliorations et tous les niveaux que le jeu peut offrir. Cliquez sur l'un d'entre eux pour commencer une nouvelle partie. ",
"unlocks.intro": "Votre score total est de {{ts}}. Vous trouverez ci-dessous toutes les améliorations et tous les niveaux que le jeu peut offrir. Cliquez sur l'un d'entre eux pour commencer une nouvelle partie rapide. ",
"unlocks.level": "Voci tous les niveaux du jeu. Cliquez sur un niveau pour commencer une nouvelle partie avec ce niveau de départ. ",
"unlocks.level_description": "Un niveau {{size}}x{{size}} avec {{bricks}} briques",
"unlocks.title": "Vous avez débloqué {{percentUnlock}}% du jeu.",
"unlocks.unlocks_at": "Déverrouillé au score total {{threshold}}.",
"upgrades.addiction.fullHelp": "Le décompte ne commence qu'à parti de la destruction de la première brique du niveau. ",
"upgrades.addiction.help": "+{{lvl}} combo / brique, le combo RAZ après {{delay}}s sans casser de briques",
"upgrades.addiction.name": "Addiction",
"upgrades.asceticism.fullHelp": "Il faudra trouver un moyen de stocker les pièces pendant que le combo grandis. ",
"upgrades.asceticism.help": "+{{combo}} combo par brique cassée, RAZ quand une pièce est attrapée",
"upgrades.asceticism.name": "Ascétisme",
@ -190,7 +186,7 @@
"upgrades.helium.help": "Les pièce flottent au lieu de tomber autours du palet",
"upgrades.helium.name": "Helium",
"upgrades.hot_start.fullHelp": "Au début de chaque niveau, votre combo commencera à +15 points, mais à chaque seconde, il sera diminué d'un point. Cela signifie que les 15 premières secondes d'un niveau produiront beaucoup plus de pièces que les suivantes.\nVous devez vous assurer de terminer le niveau rapidement. L'effet se cumule avec d'autres avantages liés au combo, ce qui vous permet d'augmenter le combo après les 15 secondes, mais il continuera à diminuer chaque seconde. Chaque fois que vous reprenez la compétence, l'effet est encore plus prononcé.",
"upgrades.hot_start.help": "Combo à {{start}}, -{{lvl}} combo par seconde",
"upgrades.hot_start.help": "Combo à {{start}}, -{{loss}} combo par seconde",
"upgrades.hot_start.name": "Démarrage à chaud",
"upgrades.implosions.fullHelp": "La force dexplosion est appliquée dans lautre sens.",
"upgrades.implosions.help": "Les explosions aspirent les pièces au lieu de les faire exploser.",
@ -233,7 +229,7 @@
"upgrades.reach.help": "+{{lvl}} combo / brique, la plus basse d'une colonne RAZ le combo",
"upgrades.reach.name": "Attaque aérienne",
"upgrades.respawn.fullHelp": "Des effets de particules vous indiqueront où les briques apparaîtront. ",
"upgrades.respawn.help": "{{percent}} des briques réapparaissent après {{delay}}s.",
"upgrades.respawn.help": "{{percent}}% des briques réapparaissent après {{delay}}s.",
"upgrades.respawn.name": "Réapparition ",
"upgrades.right_is_lava.fullHelp": "Chaque fois que vous cassez une brique, votre combo augmente d'une unité, ce qui vous permet d'obtenir une pièce de plus à chaque fois que vous cassez les briques suivantes.\n\nCependant, votre combinaison se réinitialise dès que votre balle touche le côté droit de la zone de jeu.\n\nDès que votre combo augmente, le côté droit devient rouge pour vous rappeler que vous devez éviter de le frapper.\n\nL'effet se cumule avec d'autres avantages de combo, le combo augmente plus rapidement avec plus d'améliorations, mais il se réinitialise également si l'une des conditions de réinitialisation est remplie.",
"upgrades.right_is_lava.help": "+{{lvl}} combo par brique, RAZ en cas de choc avec le coté droit",

View file

@ -62,6 +62,7 @@ export function newGameState(params: RunParams): GameState {
score: 0,
lastScoreIncrease: -1000,
lastExplosion: -1000,
lastBrickBroken: 0,
highScore: parseFloat(localStorage.getItem("breakout-3-hs") || "0"),
balls: [],
ballsColor: "white",
@ -111,6 +112,7 @@ export function newGameState(params: RunParams): GameState {
loop: 0,
baseCombo: 1,
levelsPerLoop: params?.levelsPerLoop ?? 7,
maxLoop: params?.maxLoop ?? 0,
};
resetBalls(gameState);

View file

@ -2,6 +2,7 @@ import { t } from "./i18n/i18n";
import { OptionDef, OptionId } from "./types";
import { getSettingValue, setSettingValue } from "./settings";
import { hoursSpentPlaying } from "./pure_functions";
export const options = {
sound: {
@ -55,6 +56,11 @@ export const options = {
name: t("main_menu.fullscreen"),
help: t("main_menu.fullscreen_help"),
},
donation_reminder: {
default: hoursSpentPlaying() > 5,
name: t("main_menu.donation_reminder"),
help: t("main_menu.donation_reminder_help"),
},
} as const satisfies { [k: string]: OptionDef };
export function isOptionOn(key: OptionId) {

View file

@ -1,181 +0,0 @@
import { GameState } from "./types";
import { icons } from "./loadGameData";
import { t } from "./i18n/i18n";
import { getSettingValue, setSettingValue } from "./settings";
import { asyncAlert } from "./asyncAlert";
import { openMainMenu } from "./game";
const publicKeyString = `-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyGQJgs6gxa0Fd86TuZ2q
rGQ5ArSn8ug4VIKezru1QhIEkXeOT1lYXOLEryWaVUwXfOa9sVlKAGJY5y0TarAY
NF2m67ME8yzNPIoZWbKXutJ3CSCXNTjAqAxHgz7H+qxbNGZXAXw+ta8+PuZDzcCI
LbXT1u3/i0ahhA2Erdpv9XQBazKZt5AKzU31XhEEFh1jXZyk9D4XbatYXtvEwaJx
eSWmjSxJ6SJb6oH2mwm8V4E0PxYVIa0yX3cPgGuR0pZPMleOTc6o0T24I2AUQb0d
FckdFrr5U8bFIf/nwncMYVVNgt1vh88EuzWLjpc52nLrdOkVQNpiCN2uMgBBXQB7
iseIfdkGF0A4DBn8qdieDvaSY8zeRW/nAce4FNBidU1SebNRnIU9f/XpA493lJW+
Y/zXQBbmX/uSmeZDP4fjhKZv0Qa0ZeGzZiTdBKKb0BlIg/VYFFsqPytUVVyesO4J
RCASTIjXW61E7PQKir5qIXwkQDlzJ+bpZ3PHyAvspRrBaDxIYvEEw14evpuqOgS+
v/IlgPe+CWSvZa9xxnQl/aWZrOrD7syu6KKCbgUyXEm+Alp0YT3e6nwjn0qiM/cj
dZpWPx3O+rZbRQb0gHcvN4+n2Y7fWAeC9mxVZtADqvVr/GTumMbLj7DdhWtt1Ogu
4EcvkQ5SKCL0JC93DyctjOMCAwEAAQ==
-----END PUBLIC KEY-----`;
function pemToArrayBuffer(pem: string) {
const b64 = pem
.replace(/-----BEGIN PUBLIC KEY-----/, "")
.replace(/-----END PUBLIC KEY-----/, "")
.replace(/\s+/g, "");
const binaryDerString = atob(b64);
const binaryDer = new Uint8Array(binaryDerString.length);
for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i);
}
return binaryDer.buffer;
}
async function getPriceId(key: string, pem: string) {
// Split the key into its components
const [priceId, timestamp, signature] = key.split(":");
const data = `${priceId}:${timestamp}`;
const publicKeyBuffer = pemToArrayBuffer(pem);
const publicKey = await crypto.subtle.importKey(
"spki",
publicKeyBuffer,
{
name: "RSA-PSS",
hash: "SHA-256",
},
true,
["verify"],
);
// Verify the signature using ECDSA
const isValid = await crypto.subtle.verify(
{
name: "RSA-PSS",
saltLength: 32,
},
publicKey,
new Uint8Array(Array.from(atob(signature), (c) => c.charCodeAt(0))),
new TextEncoder().encode(data),
);
if (!isValid) throw new Error("Invalid key signature");
return priceId;
}
let premium = false;
const gamePriceId = "price_1R6YaEGRf74lr2EkSo2GPvuO";
checkKey(getSettingValue("license", "")).then();
async function checkKey(key: string) {
if (!key) return "No key";
try {
if (gamePriceId !== (await getPriceId(key, publicKeyString))) {
return "Wrong product";
}
premium = true;
return "";
} catch (e) {
return "Could not upgrade : " + e.message;
}
}
export function isPremium() {
return premium;
}
export function premiumMenuEntry(gameState: GameState) {
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);
}
}
} catch (e) {
console.warn(e);
}
return {
icon: icons["icon:premium"],
text,
help,
value: () => openPremiumMenu(""),
};
}
const isGooglePlayInstall =
new URLSearchParams(location.search).get("source") === "com.android.vending";
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 || "");
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

@ -5,3 +5,13 @@ export function clamp(value: number, min: number, max: number) {
export function comboKeepingRate(level: number) {
return clamp(1 - (1 / (1 + level)) * 1.5, 0, 1);
}
export function hoursSpentPlaying() {
try {
const timePlayed =
localStorage.getItem("breakout_71_total_play_time") || "0";
return Math.floor(parseFloat(timePlayed) / 1000 / 60 / 60);
} catch (e) {
return 0;
}
}

View file

@ -27,6 +27,7 @@ bombSVG.src =
bombSVG.onload = () => (gameState.needsRender = true);
export const background = document.createElement("img");
background.onload = () => (gameState.needsRender = true);
export const backgroundCanvas = document.createElement("canvas");
export function render(gameState: GameState) {
@ -67,16 +68,16 @@ export function render(gameState: GameState) {
: "") +
(isOptionOn("show_stats")
? `
<span class="${(catchRate == 1 && "great") || (catchRate > 0.9 && "good") || ""}">
<span class="${(catchRate > 0.95 && "great") || (catchRate > 0.9 && "good") || ""}">
${Math.floor(catchRate * 100)}%
</span><span> / </span>
<span class="${(gameState.levelWallBounces == 0 && "great") || (gameState.levelWallBounces < 5 && "good") || ""}">
${gameState.levelWallBounces} B
</span><span> / </span>
<span class="${(gameState.levelTime < 30000 && "great") || (gameState.levelTime < 60000 && "good") || ""}">
${Math.ceil(gameState.levelTime / 1000)}s
</span><span> / </span>
<span class="${(gameState.levelMisses == 0 && "great") || (gameState.levelMisses <= 3 && "good") || ""}">
<span class="${(gameState.levelWallBounces < 3 && "great") || (gameState.levelWallBounces < 10 && "good") || ""}">
${gameState.levelWallBounces} B
</span><span> / </span>
<span class="${(gameState.levelMisses < 3 && "great") || (gameState.levelMisses < 6 && "good") || ""}">
${gameState.levelMisses} M
</span><span> / </span>
`
@ -628,11 +629,12 @@ export function renderAllBricks() {
gameState.perks.clairvoyant >= 2,
);
if (gameState.brickHP[index] > 1 && gameState.perks.clairvoyant) {
canctx.globalCompositeOperation = "destination-out";
canctx.globalCompositeOperation =
gameState.perks.clairvoyant >= 2 ? "source-over" : "destination-out";
drawText(
canctx,
gameState.brickHP[index].toString(),
"white",
color,
gameState.puckHeight,
x,
y,

View file

@ -49,16 +49,16 @@ export const sounds = {
if (!isOptionOn("sound")) return;
createSingleBounceSound(1200, pan, volume, 0.1, "triangle");
},
void: (volume: number, pan: number) => {
if (!isOptionOn("sound")) return;
createSingleBounceSound(1200, pan, volume, 0.5, "sawtooth");
createSingleBounceSound(600, pan, volume, 0.3, "sawtooth");
},
freeze: (volume: number, pan: number) => {
if (!isOptionOn("sound")) return;
createSingleBounceSound(220, pan, volume, 0.5, "square");
createSingleBounceSound(440, pan, volume, 0.5, "square");
},
// void: (volume: number, pan: number) => {
// if (!isOptionOn("sound")) return;
// createSingleBounceSound(1200, pan, volume, 0.5, "sawtooth");
// createSingleBounceSound(600, pan, volume, 0.3, "sawtooth");
// },
// freeze: (volume: number, pan: number) => {
// if (!isOptionOn("sound")) return;
// createSingleBounceSound(220, pan, volume, 0.5, "square");
// createSingleBounceSound(440, pan, volume, 0.5, "square");
// },
explode: (volume: number, pan: number, combo: number) => {
if (!isOptionOn("sound")) return;
createExplosionSound(pan);

5
src/types.d.ts vendored
View file

@ -221,6 +221,7 @@ export type GameState = {
lastScoreIncrease: number;
// levelTime of the last explosion, for screen shake
lastExplosion: number;
lastBrickBroken: number;
// High score at the beginning of the run
highScore: number;
// Balls currently in game, game over if it's empty
@ -278,13 +279,12 @@ export type GameState = {
lifeLost: { vol: number; x: number };
coinCatch: { vol: number; x: number };
colorChange: { vol: number; x: number };
void: { vol: number; x: number };
freeze: { vol: number; x: number };
};
rerolls: number;
loop: number;
baseCombo: number;
levelsPerLoop: number;
maxLoop: number;
};
export type RunParams = {
@ -292,6 +292,7 @@ export type RunParams = {
levelToAvoid?: string;
perks?: Partial<PerksMap>;
levelsPerLoop?: number;
maxLoop?: number;
};
export type OptionDef = {
default: boolean;

View file

@ -128,8 +128,8 @@ export const rawUpgrades = [
threshold: 500,
id: "telekinesis",
giftable: false,
max: 2,
giftable: true,
max: 1,
name: t("upgrades.telekinesis.name"),
help: (lvl: number) =>
lvl == 1
@ -156,7 +156,7 @@ export const rawUpgrades = [
threshold: 1500,
id: "multiball",
giftable: false,
giftable: true,
max: 6,
name: t("upgrades.multiball.name"),
help: (lvl: number) => t("upgrades.multiball.help", { count: lvl + 1 }),
@ -229,8 +229,8 @@ export const rawUpgrades = [
name: t("upgrades.hot_start.name"),
help: (lvl: number) =>
t("upgrades.hot_start.help", {
start: lvl * 15 + 1,
lvl,
start: lvl * 30 + 1,
loss: lvl,
}),
fullHelp: t("upgrades.hot_start.fullHelp"),
},
@ -357,7 +357,7 @@ export const rawUpgrades = [
name: t("upgrades.sturdy_bricks.name"),
help: (lvl: number) =>
// lvl == 1
t("upgrades.sturdy_bricks.help", { lvl, percent: lvl * 10 }),
t("upgrades.sturdy_bricks.help", { lvl, percent: lvl * 50 }),
// ?
// : t("upgrades.sturdy_bricks.help_plural"),
fullHelp: t("upgrades.sturdy_bricks.fullHelp"),
@ -465,7 +465,7 @@ export const rawUpgrades = [
threshold: 85000,
giftable: false,
id: "yoyo",
max: 2,
max: 1,
name: t("upgrades.yoyo.name"),
help: (lvl: number) => t("upgrades.yoyo.help"),
fullHelp: t("upgrades.yoyo.fullHelp"),
@ -596,7 +596,7 @@ export const rawUpgrades = [
threshold: 145000,
giftable: false,
id: "clairvoyant",
max: 1,
max: 3,
name: t("upgrades.clairvoyant.name"),
help: (lvl: number) => t("upgrades.clairvoyant.help"),
fullHelp: t("upgrades.clairvoyant.fullHelp"),
@ -632,4 +632,15 @@ export const rawUpgrades = [
help: (lvl: number) => t("upgrades.corner_shot.help"),
fullHelp: t("upgrades.corner_shot.fullHelp"),
},
{
requires: "",
threshold: 165000,
giftable: false,
id: "addiction",
max: 10,
name: t("upgrades.addiction.name"),
help: (lvl: number) =>
t("upgrades.addiction.help", { lvl, delay: (5 / lvl).toFixed(2) }),
fullHelp: t("upgrades.addiction.fullHelp"),
},
] as const;