Looping works, almost

This commit is contained in:
Renan LE CARO 2025-03-28 11:58:58 +01:00
parent 46f87556e1
commit 3d5547e786
12 changed files with 1116 additions and 918 deletions

View file

@ -27,19 +27,21 @@ export const debuffs = [
help: (lvl: number) => t("debuffs.interference.help", { lvl }),
},
{
id: "fragility",
max: 5,
name: (lvl: number) => t("debuffs.fragility.help", { percent:lvl*20 }),
help: (lvl: number) => t("debuffs.fragility.help", { percent:lvl*20 }),
},
] as const as Debuff[];
/*
Possible challenges :
- interference : telekinesis works backward for lvl/2 seconds every 5 seconds (show timer ?)
- exclusion : one of your current perks (except the kept one) is banned
- fireworks : some bricks are explosive, you're not told which ones
-
- graphical effects like trail, contrast, blur to make it harder to see what's going on
- ball creates a draft behind itself that blows coins in odd patterns
- bricks are invisible
- downward wind
- side wind
- add red anti-coins that apply downgrades

View file

@ -1008,22 +1008,26 @@ restart(
(window.location.search.includes("stressTest") && {
level: "Bird",
perks: {
// sapper: 1,
// sapper: 5,
// bigger_explosions: 20,
// // unbounded: 1,
// // pierce_color: 1,
// pierce: 1,
streak_shots:1,
// multiball: 6,
// base_combo: 7,
telekinesis: 2,
yoyo: 2,
// telekinesis: 2,
// yoyo: 2,
pierce:10,
// metamorphosis: 1,
// implosions: 1,
// sturdy_bricks:5
extra_life:3
},
debuffs:{
// fragility:3
negative_coins:1
// interference:20,
}
}) ||
{},

View file

@ -143,12 +143,12 @@ export function gameOver(title: string, intro: string) {
export function getHistograms() {
let runStats = "";
// TODO separate adventure and normal runs
try {
// Stores only top 100 runs
let runsHistory = JSON.parse(
localStorage.getItem("breakout_71_runs_history") || "[]",
) as RunHistoryItem[];
runsHistory.sort((a, b) => a.score - b.score).reverse();
runsHistory = runsHistory.slice(0, 100);

View file

@ -36,7 +36,16 @@ import {icons, upgrades} from "./loadGameData";
import {addToTotalScore, getCurrentMaxCoins, getCurrentMaxParticles,} from "./settings";
import {background} from "./render";
import {gameOver} from "./gameOver";
import {brickIndex, fitSize, gameState, hasBrick, hitsSomething, openShortRunUpgradesPicker, pause,} from "./game";
import {
brickIndex,
fitSize,
gameState,
hasBrick,
hitsSomething,
openShortRunUpgradesPicker,
pause,
play,
} from "./game";
import {stopRecording} from "./recording";
import {isOptionOn} from "./options";
import {isPremium} from "./premium";
@ -324,6 +333,16 @@ export function explosionAt(
if (gameState.perks.zen) {
resetCombo(gameState, x, y);
}
if(gameState.debuffs.fragility){
resetCombo(gameState, x, y);
forEachLiveOne(gameState.coins, (coin, index)=>{
// Also destroys cursed coins
if(Math.random()<gameState.debuffs.fragility/5){
destroy(gameState.coins, index)
}
})
}
}
export function explodeBrick(
@ -335,14 +354,14 @@ export function explodeBrick(
const color = gameState.bricks[index];
if (!color) return;
if (color === "black" || color === "transparent") {
if (color === "black") {
const x = brickCenterX(gameState, index),
y = brickCenterY(gameState, index);
if (color === "transparent") {
schedulGameSound(gameState, "void", x, 1);
resetCombo(gameState, x, y);
}
// if (color === "transparent") {
// schedulGameSound(gameState, "void", x, 1);
// resetCombo(gameState, x, y);
// }
setBrick(gameState, index, "");
explosionAt(gameState, index, x, y, ball);
} else if (color) {
@ -534,7 +553,6 @@ export function schedulGameSound(
export function addToScore(gameState: GameState, coin: Coin) {
gameState.score += coin.points;
gameState.lastScoreIncrease = gameState.levelTime;
addToTotalScore(gameState, coin.points);
if (
gameState.score > gameState.highScore &&
@ -543,7 +561,7 @@ export function addToScore(gameState: GameState, coin: Coin) {
gameState.highScore = gameState.score;
localStorage.setItem("breakout-3-hs", gameState.score.toString());
}
if (!isOptionOn("basic")) {
if (!isOptionOn("basic") ) {
makeParticle(
gameState,
coin.previousX,
@ -557,11 +575,7 @@ export function addToScore(gameState: GameState, coin: Coin) {
);
}
if (coin.points > 0) {
schedulGameSound(gameState, "coinCatch", coin.x, 1);
} else {
resetCombo(gameState, coin.x, coin.y);
}
schedulGameSound(gameState, "coinCatch", coin.x, 1);
gameState.runStatistics.score += coin.points;
if (gameState.perks.asceticism) {
resetCombo(gameState, coin.x, coin.y);
@ -571,6 +585,7 @@ export function addToScore(gameState: GameState, coin: Coin) {
export async function gotoNextLoop(gameState: GameState) {
pause(false)
gameState.loop++
gameState.runStatistics.loops++
gameState.runLevels = getRunLevels(gameState.totalScoreAtRunStart, {})
gameState.upgradesOfferedFor = -1
// Add random debuf
@ -867,7 +882,7 @@ export function bordersHitCheck(
(gameState.offsetX + gameState.gameZoneWidth / 2)) /
gameState.gameZoneWidth) *
gameState.perks.wind *
0.5;
0.5 ;
}
let vhit = 0,
@ -1015,7 +1030,7 @@ export function gameStateTick(
});
}
const ratio = 1 - (gameState.perks.viscosity * 0.03 + 0.005) * frames;
const ratio = 1 - ((coin.color==='crimson' ? 2:gameState.perks.viscosity)* 0.03 + 0.005) * frames;
coin.vy *= ratio;
coin.vx *= ratio;
@ -1061,7 +1076,16 @@ export function gameStateTick(
// a bit of margin to be nice , negative in case it's a negative coin
gameState.puckHeight * (coin.points ? 1 : -1)
) {
addToScore(gameState, coin);
if(coin.points) {
addToScore(gameState, coin);
}else if(gameState.perks.extra_life && gameState.balls.length){
justLostALife(gameState, gameState.balls[0], coin.x,coin.y)
}else{
gameOver(
t('gameOver.because_cursed_coin'),
t('gameOver.because_cursed_coin_intro')
)
}
destroy(gameState.coins, coinIndex);
} else if (coin.y > gameState.canvasHeight + coinRadius) {
destroy(gameState.coins, coinIndex);
@ -1317,12 +1341,15 @@ export function ballTick(gameState: GameState, ball: Ball, delta: number) {
ball.vx +=
((gameState.puckPosition - ball.x) / 1000) *
delta *
gameState.perks.telekinesis;
gameState.perks.telekinesis
* interferenceFactor(gameState)
;
}
if (isYoyoActive(gameState, ball)) {
speedLimitDampener += 3;
ball.vx +=
((gameState.puckPosition - ball.x) / 1000) * delta * gameState.perks.yoyo;
((gameState.puckPosition - ball.x) / 1000) * delta * gameState.perks.yoyo
* interferenceFactor(gameState);
}
if (
ball.vx * ball.vx + ball.vy * ball.vy <
@ -1466,31 +1493,8 @@ export function ballTick(gameState: GameState, ball: Ball, delta: number) {
schedulGameSound(gameState, "wallBeep", ball.x, 1);
} else {
ball.vy *= -1;
justLostALife(gameState, ball, ball.x,ball.y)
gameState.perks.extra_life -= 1;
if (gameState.perks.extra_life < 0) {
gameState.perks.extra_life = 0;
} else if (gameState.perks.sacrifice) {
gameState.bricks.forEach(
(color, index) => color && explodeBrick(gameState, index, ball, true),
);
}
schedulGameSound(gameState, "lifeLost", ball.x, 1);
if (!isOptionOn("basic")) {
for (let i = 0; i < 10; i++)
makeParticle(
gameState,
ball.x,
ball.y,
Math.random() * gameState.baseSpeed * 3,
gameState.baseSpeed * 3,
"red",
false,
gameState.coinSize / 2,
150,
);
}
}
if (gameState.perks.streak_shots) {
resetCombo(gameState, ball.x, ball.y);
@ -1667,6 +1671,34 @@ export function ballTick(gameState: GameState, ball: Ball, delta: number) {
}
}
function justLostALife(gameState:GameState, ball:Ball, x:number,y:number){
gameState.perks.extra_life -= 1;
if (gameState.perks.extra_life < 0) {
gameState.perks.extra_life = 0;
} else if (gameState.perks.sacrifice) {
gameState.bricks.forEach(
(color, index) => color && explodeBrick(gameState, index, ball, true),
);
}
schedulGameSound(gameState, "lifeLost", ball.x, 1);
if (!isOptionOn("basic")) {
for (let i = 0; i < 10; i++)
makeParticle(
gameState,
x,
y,
Math.random() * gameState.baseSpeed * 3,
gameState.baseSpeed * 3,
"red",
false,
gameState.coinSize / 2,
150,
);
}
}
function makeCoin(
gameState: GameState,
x: number,
@ -1676,9 +1708,9 @@ function makeCoin(
color = "gold",
points = 1,
) {
if (gameState.debuffs.negative_coins > Math.random() * 100) {
if (gameState.debuffs.negative_coins *points> Math.random() * 10000) {
points = 0;
color = "transparent";
color = "crimson";
}
append(gameState.coins, (p: Partial<Coin>) => {
p.x = x;
@ -1698,6 +1730,13 @@ function makeCoin(
});
}
export function interferenceFactor(gameState:GameState){
if(!gameState.debuffs.interference) return 1
const cycleLength = (7+gameState.debuffs.interference)*1000
const position = gameState.levelTime % cycleLength
return position>7000 ? -1 :1
}
function makeParticle(
gameState: GameState,
x: number,

View file

@ -55,8 +55,7 @@ export function getPossibleUpgrades(gameState: GameState) {
}
export function max_levels(gameState: GameState) {
// TODO
return 2
return 7 + gameState.perks.extra_levels;
}
@ -74,12 +73,10 @@ export function pickedUpgradesHTMl(gameState: GameState) {
export function debuffsHTMl(gameState: GameState):string {
const banned = upgrades.filter(u=>gameState.bannedPerks[u.id]).map(u=>u.name).join(', ')
let list = debuffs.filter(d=>gameState.debuffs[d.id]).map(d=>d.name(gameState.debuffs[d.id], banned)).join(', ');
let list = debuffs.filter(d=>gameState.debuffs[d.id]).map(d=>d.name(gameState.debuffs[d.id], banned)).join(' ');
if (!list) return "";
return `<p>${t("score_panel.bebuffs_list")} : ${list}</p>`;
return `<p>${t("score_panel.bebuffs_list")} ${list}</p>`;
}
export function levelsListHTMl(gameState: GameState) {

View file

@ -116,7 +116,27 @@
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<folder_node>
<name>fragility</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>
@ -176,7 +196,7 @@
</translation>
<translation>
<language>fr-FR</language>
<approved>true</approved>
<approved>false</approved>
</translation>
</translations>
</concept_node>
@ -187,6 +207,36 @@
<folder_node>
<name>gameOver</name>
<children>
<concept_node>
<name>because_cursed_coin</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>because_cursed_coin_intro</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>cumulative_total</name>
<description/>

View file

@ -3,11 +3,14 @@
"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",
"debuffs.banned.description": "{{lvl}} banned perk(s) : {{banned}}",
"debuffs.banned.help": "{{perk}} is banned for this run",
"debuffs.interference.help": "Telekinesis and yo-yo stop working for {{lvl}}s every 7s",
"debuffs.more_bombs.help": "{{lvl}} bricks replaced by bombs",
"debuffs.negative_coins.help": "{{lvl}}% of coins spawn void and break the combo if caught",
"debuffs.banned.description": "{{lvl}} banned perk(s) : {{banned}}.",
"debuffs.banned.help": "{{perk}} is banned for this run.",
"debuffs.fragility.help": "Explosions destroy {{percent}} of coins and reset combo.",
"debuffs.interference.help": "Telekinesis and yo-yo glitch for {{lvl}}s every 7s.",
"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.",
"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.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",
@ -68,7 +71,7 @@
"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 7 levels run",
"main_menu.normal": "New 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.",
@ -122,7 +125,7 @@
"sandbox.start": "Start test run",
"sandbox.title": "Sandbox mode",
"sandbox.unlocks_at": "Unlocks at total score {{score}}",
"score_panel.bebuffs_list": "Debuffs :",
"score_panel.bebuffs_list": "De-buffs :",
"score_panel.test_run": "This is a test run, score is not recorded permanently",
"score_panel.title": "{{score}} points at level {{level}}/{{max}} ",
"score_panel.title_looped": "{{score}} points at level {{level}}/{{max}} of loop {{loop}}",

View file

@ -3,11 +3,14 @@
"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",
"debuffs.banned.description": "{{lvl}} amélioration(s) bannie(s) : {{banned}}",
"debuffs.banned.help": "",
"debuffs.interference.help": "",
"debuffs.more_bombs.help": "",
"debuffs.negative_coins.help": "",
"debuffs.banned.description": "{{lvl}} amélioration(s) bannie(s) : {{banned}}.",
"debuffs.banned.help": "{{perk}} est banni pour cette course.",
"debuffs.fragility.help": "Les explosions détruisent {{percent}} pièces et réinitialisent le combo.",
"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.negative_coins.help": "{{lvl}}/10000 pièces apparaissent maudites et clignotent en rouge. La partie est terminée si vous les attrapez.",
"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.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",
@ -43,12 +46,12 @@
"level_up.unlocked_level": " (Niveau)",
"level_up.unlocked_perk": " (Amélioration)",
"level_up.upgrade_perk_to_level": " niveau {{level}}",
"loop.instructions": "",
"loop.title": "",
"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.title": "Boucle de départ {{loop}}",
"main_menu.basic": "Graphismes simplifiés",
"main_menu.basic_help": "Meilleures performances.",
"main_menu.colorful_coins": "",
"main_menu.colorful_coins_help": "",
"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.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>",
@ -68,8 +71,8 @@
"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 rapide",
"main_menu.normal_help": "Jouez 7 niveaux avec un avantage de départ aléatoire",
"main_menu.normal": "Nouvelle partie",
"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.",
"main_menu.record": "Enregistrer des vidéos de jeu",
@ -90,8 +93,8 @@
"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": "",
"main_menu.show_stats_help": "",
"main_menu.show_stats": "Afficher les 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.",
"main_menu.title": "Breakout 71",
@ -114,9 +117,9 @@
"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": "",
"premium.thanks_help": "",
"premium.title": "",
"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",

File diff suppressed because it is too large Load diff

3
src/types.d.ts vendored
View file

@ -137,6 +137,7 @@ interface LightFlash extends BaseFlash {
export type RunStats = {
started: number;
levelsPlayed: number;
loops: number;
runTime: number;
coins_spawned: number;
score: number;
@ -292,7 +293,7 @@ export type RunParams = {
level?: string;
levelToAvoid?: string;
perks?: Partial<PerksMap>;
debuffs?: boolean;
debuffs?: Partial<DebuffsMap>;
};
export type OptionDef = {
default: boolean;