mirror of
https://gitlab.com/lecarore/breakout71.git
synced 2025-04-20 12:15:06 -04:00
Wip : adventure mode
This commit is contained in:
parent
395968bc52
commit
6cf8fabf16
14 changed files with 457 additions and 114 deletions
88
Readme.md
88
Readme.md
|
@ -13,6 +13,51 @@ Break colourful bricks, catch bouncing coins and select powerful upgrades !
|
|||
- [GitLab](https://gitlab.com/lecarore/breakout71)
|
||||
- [HackerNews thread](https://news.ycombinator.com/item?id=43183131)
|
||||
|
||||
# Todo
|
||||
- bring back detailed help of perks as "intel"
|
||||
- people assume unbounded allows for wrap around
|
||||
- coin magnet and viscosity : only one level ~2.5
|
||||
- Boost Ascetism : give +2 or even +3 combo per brick destroyed
|
||||
- wind : move coins based on puck movement not position
|
||||
- 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
|
||||
- respawn: N% of bricks respawn after N seconds
|
||||
|
||||
|
||||
# Premium: infinite mode
|
||||
|
||||
Allow players to loop the game, adding one hasard per loop, making it harder and harder to exploit each strategy.
|
||||
The high score are separated from the main mode. The scores are added for unlock. You no longer get upgrades after the first 7 levels.
|
||||
The score you make in each level is instead multiplied by the number of "upgrades" and "choices" you would have had.
|
||||
|
||||
The score is your "fuel", and lets you pick the next level from a list. Each level has a cost, preview, and one or two downgrades.
|
||||
Each downgrade acts as a score multiplier.
|
||||
Your goal is no longer to score higher, but to go farther
|
||||
|
||||
# Challenges
|
||||
|
||||
Possible challenges :
|
||||
- Add negative coins that make the coin magnet less usage
|
||||
- add negative bricks that clear coins and reset combo
|
||||
- add a brick eating enemy that forces you to play fast
|
||||
- add a force field for 10s that negates hots start
|
||||
- other perks can be randomly turned off
|
||||
- ball keeps accelerating until unplayable
|
||||
- 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
|
||||
|
||||
- add red anti-coins that apply downgrades
|
||||
- destroy your combo
|
||||
- hurt your score
|
||||
- behave like heavier coins.
|
||||
- deactivate a perk for this level
|
||||
- reduce your number of coins
|
||||
- destroy all coins on screen
|
||||
- lowers your combo
|
||||
- reduce your choice for your next perk
|
||||
|
||||
|
||||
|
||||
# System requirements
|
||||
|
||||
|
@ -21,16 +66,6 @@ It's very lean and does not take much storage space (Roughly 0.1MB).
|
|||
If the app stutters, turn on "fast mode" in the settings to render a simplified view that should be faster.
|
||||
There's also an easy mode for kids (slower ball).
|
||||
|
||||
# Todo
|
||||
- people assume unbounded allows for wrap around
|
||||
- popups not scrollable sometimes
|
||||
- fdroid build
|
||||
- coin magnet and viscosity : only one level ~2.5
|
||||
- Boost Ascetism : give +2 or even +3 combo per brick destroyed
|
||||
- wind : move coins based on puck movement not position
|
||||
- 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
|
||||
- respawn: N% of bricks respawn after N seconds
|
||||
|
||||
# UX
|
||||
|
||||
|
@ -166,39 +201,6 @@ There's also an easy mode for kids (slower ball).
|
|||
- on mobile, relative movement of the touch would be amplified and added to the puck
|
||||
- option : don't pause on mobile when lifting finger
|
||||
|
||||
# Premium: infinite mode
|
||||
|
||||
Allow players to loop the game, adding one hasard per loop, making it harder and harder to exploit each strategy.
|
||||
The high score are separated from the main mode. The scores are added for unlock. You no longer get upgrades after the first 7 levels.
|
||||
The score you make in each level is instead multiplied by the number of "upgrades" and "choices" you would have had.
|
||||
|
||||
The score is your "fuel", and lets you pick the next level from a list. Each level has a cost, preview, and one or two downgrades.
|
||||
Each downgrade acts as a score multiplier.
|
||||
Your goal is no longer to score higher, but to go farther
|
||||
|
||||
# Challenges
|
||||
|
||||
Possible challenges :
|
||||
- Add negative coins that make the coin magnet less usage
|
||||
- add negative bricks that clear coins and reset combo
|
||||
- add a brick eating enemy that forces you to play fast
|
||||
- add a force field for 10s that negates hots start
|
||||
- other perks can be randomly turned off
|
||||
- ball keeps accelerating until unplayable
|
||||
- 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
|
||||
|
||||
- add red anti-coins that apply downgrades
|
||||
- destroy your combo
|
||||
- hurt your score
|
||||
- behave like heavier coins.
|
||||
- deactivate a perk for this level
|
||||
- reduce your number of coins
|
||||
- destroy all coins on screen
|
||||
- lowers your combo
|
||||
- reduce your choice for your next perk
|
||||
|
||||
|
||||
# extend re-playability
|
||||
- hard mode : bricks take many hits, perks more rare, missing clears level score, missing coins deducts score..
|
||||
|
|
249
dist/index.html
vendored
249
dist/index.html
vendored
File diff suppressed because one or more lines are too long
102
src/adventure.ts
102
src/adventure.ts
|
@ -1,26 +1,110 @@
|
|||
import { GameState } from "./types";
|
||||
import {GameState, PerksMap} from "./types";
|
||||
import {sample, sumOfValues} from "./game_utils";
|
||||
import {allLevels, icons, upgrades} from "./loadGameData";
|
||||
import {t} from "./i18n/i18n";
|
||||
import {hashCode} from "./getLevelBackground";
|
||||
import {requiredAsyncAlert} from "./asyncAlert";
|
||||
|
||||
const MAX_DIFFICULTY = 3+4
|
||||
export async function openAdventureRunUpgradesPicker(gameState: GameState) {
|
||||
let options = 3;
|
||||
let maxDifficulty = 3;
|
||||
|
||||
const catchRate =
|
||||
(gameState.score - gameState.levelStartScore) /
|
||||
(gameState.levelSpawnedCoins || 1);
|
||||
|
||||
if (gameState.levelWallBounces == 0) {
|
||||
options++;
|
||||
maxDifficulty++;
|
||||
}
|
||||
if (gameState.levelTime < 30 * 1000) {
|
||||
options++;
|
||||
maxDifficulty++;
|
||||
}
|
||||
if (catchRate === 1) {
|
||||
options++;
|
||||
maxDifficulty++;
|
||||
}
|
||||
if (gameState.levelMisses === 0) {
|
||||
options++;
|
||||
maxDifficulty++;
|
||||
}
|
||||
|
||||
const choices = [];
|
||||
for (let difficulty = 0; difficulty < options; difficulty++) {
|
||||
choices.push({});
|
||||
let actions = range(0, maxDifficulty).map(difficulty => getPerksForPath(gameState.score, gameState.currentLevel, gameState.seed, gameState.adventurePath, gameState.perks, difficulty))
|
||||
|
||||
return requiredAsyncAlert({
|
||||
title: 'Choose your next step',
|
||||
text: 'Click one of the options below to continue',
|
||||
actions,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function getPerksForPath(score: number, currentLevel: number, seed: string, path: string, basePerks: PerksMap, difficulty: number) {
|
||||
const hashSeed = seed + path
|
||||
let cost = (1 + difficulty) * Math.pow(1.5, currentLevel) * 10
|
||||
if (!difficulty && cost > score) {
|
||||
cost = score
|
||||
}
|
||||
|
||||
const levels = allLevels
|
||||
.sort((a, b) => hashCode(hashSeed + a.name) - hashCode(hashSeed + b.name))
|
||||
.slice(0, MAX_DIFFICULTY)
|
||||
.sort((a,b)=>a.size-b.size)
|
||||
|
||||
let level = levels[difficulty]
|
||||
let text = level.name + ' $' + cost, help = []
|
||||
|
||||
let perks = {}
|
||||
// TODO exclude irrelevant perks
|
||||
|
||||
|
||||
upgrades
|
||||
.filter((u) => !u?.requires || basePerks[u?.requires])
|
||||
.filter(u => basePerks[u.id] < u.max)
|
||||
.sort((a, b) => hashCode(hashSeed + difficulty+a.id) - hashCode(hashSeed + difficulty+b.id))
|
||||
.slice(0, difficulty+1)
|
||||
.forEach(u => {
|
||||
perks[u.id] = basePerks[u.id] + 1
|
||||
help.push(u.name +
|
||||
(basePerks[u.id]
|
||||
? t("level_up.upgrade_perk_to_level", {
|
||||
level: basePerks[u.id] + 1,
|
||||
})
|
||||
: ""))
|
||||
})
|
||||
|
||||
let totalPerksValue = sumOfValues({...basePerks, ...perks})
|
||||
let targetPerks = 10 + difficulty * 3
|
||||
let toRemove = Math.max(0, totalPerksValue - targetPerks)
|
||||
while (toRemove) {
|
||||
const possibleDowngrades = Object.keys(basePerks).filter(
|
||||
k => !perks[k] && basePerks[k] > 0
|
||||
)
|
||||
if (!possibleDowngrades.length) {
|
||||
break
|
||||
}
|
||||
const downGraded = sample(possibleDowngrades)
|
||||
|
||||
perks[downGraded] = basePerks[downGraded] - 1
|
||||
if (!perks[downGraded]) {
|
||||
help.push(t('level_up.perk_loss') + upgrades.find(u => u.id == downGraded)?.help(1))
|
||||
} else {
|
||||
help.push(t('level_up.downgrade') + upgrades.find(u => u.id == downGraded)?.help(perks[downGraded]))
|
||||
}
|
||||
toRemove--
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
cost,
|
||||
level,
|
||||
perks,
|
||||
difficulty
|
||||
},
|
||||
icon: icons[level.name],
|
||||
disabled: cost > score,
|
||||
text, help: help.join('/') || 'No change to perks',
|
||||
}
|
||||
}
|
||||
|
||||
function range(start: number, end: number): number[] {
|
||||
const result = []
|
||||
for (let i = start; i < end; i++) result.push(i)
|
||||
return result
|
||||
}
|
|
@ -25,6 +25,16 @@ closeModaleButton.title = t("play.close_modale_window_tooltip");
|
|||
|
||||
let lastClickedItemIndex = -1;
|
||||
|
||||
export function requiredAsyncAlert<t>(p: {
|
||||
title?: string;
|
||||
text?: string;
|
||||
actions?: AsyncAlertAction<t>[];
|
||||
textAfterButtons?: string;
|
||||
actionsAsGrid?: boolean;
|
||||
}):Promise<t>{
|
||||
return asyncAlert({...p, allowClose:false})
|
||||
}
|
||||
|
||||
export async function asyncAlert<t>({
|
||||
title,
|
||||
text,
|
||||
|
|
24
src/game.ts
24
src/game.ts
|
@ -58,7 +58,7 @@ import {
|
|||
alertsOpen,
|
||||
asyncAlert,
|
||||
AsyncAlertAction,
|
||||
closeModal,
|
||||
closeModal, requiredAsyncAlert,
|
||||
} from "./asyncAlert";
|
||||
import { isOptionOn, options, toggleOption } from "./options";
|
||||
import { hashCode } from "./getLevelBackground";
|
||||
|
@ -262,7 +262,7 @@ export async function openShortRunUpgradesPicker(gameState: GameState) {
|
|||
t("level_up.compliment_good")) ||
|
||||
t("level_up.compliment_advice");
|
||||
|
||||
const upgradeId = (await asyncAlert<PerkId | "reroll">({
|
||||
const upgradeId = await requiredAsyncAlert<PerkId | "reroll">({
|
||||
title:
|
||||
t("level_up.pick_upgrade_title") +
|
||||
(repeats ? " (" + (repeats + 1) + ")" : ""),
|
||||
|
@ -283,9 +283,8 @@ export async function openShortRunUpgradesPicker(gameState: GameState) {
|
|||
|
||||
<p>${levelsListHTMl(gameState)}</p>
|
||||
`,
|
||||
allowClose: false,
|
||||
textAfterButtons,
|
||||
})) as PerkId;
|
||||
}) ;
|
||||
|
||||
if (upgradeId === "reroll") {
|
||||
repeats++;
|
||||
|
@ -533,7 +532,7 @@ export async function openMainMenu() {
|
|||
},
|
||||
},
|
||||
|
||||
// premiumMenuEntry(gameState),
|
||||
premiumMenuEntry(gameState),
|
||||
{
|
||||
text: t("main_menu.settings_title"),
|
||||
help: t("main_menu.settings_help"),
|
||||
|
@ -991,8 +990,7 @@ export function restart(params: RunParams) {
|
|||
}
|
||||
|
||||
restart(
|
||||
window.location.search.includes("stressTest")
|
||||
? {
|
||||
(window.location.search.includes("stressTest") && {
|
||||
level: "Bird",
|
||||
perks: {
|
||||
sapper: 10,
|
||||
|
@ -1001,13 +999,19 @@ restart(
|
|||
pierce_color: 1,
|
||||
pierce: 20,
|
||||
multiball: 6,
|
||||
base_combo: 100,
|
||||
base_combo: 7,
|
||||
telekinesis: 2,
|
||||
yoyo: 2,
|
||||
metamorphosis: 1,
|
||||
implosions: 1,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
}) ||(window.location.search.includes("adventure") && {
|
||||
adventure:true,
|
||||
perks: {
|
||||
pierce:5
|
||||
},
|
||||
}) || {},
|
||||
);
|
||||
|
||||
|
||||
tick();
|
||||
|
|
|
@ -492,7 +492,7 @@ export function pickRandomUpgrades(gameState: GameState, count: number) {
|
|||
let list = getPossibleUpgrades(gameState)
|
||||
.map((u) => ({
|
||||
...u,
|
||||
score: Math.random() + (gameState.lastOffered[u.id] || 0),
|
||||
score: gameState.isAdventureMode ? 0:Math.random() + (gameState.lastOffered[u.id] || 0),
|
||||
}))
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.filter((u) => gameState.perks[u.id] < u.max)
|
||||
|
@ -576,7 +576,16 @@ export async function setLevel(gameState: GameState, l: number) {
|
|||
stopRecording();
|
||||
if (l > 0) {
|
||||
if (gameState.isCreativeModeRun) {
|
||||
await openAdventureRunUpgradesPicker(gameState);
|
||||
const { cost,
|
||||
level,
|
||||
perks,difficulty} = await openAdventureRunUpgradesPicker(gameState);
|
||||
|
||||
gameState.score-=cost
|
||||
gameState.runLevels[l] = level
|
||||
gameState.adventurePath += difficulty
|
||||
Object.assign(gameState.perks, perks)
|
||||
|
||||
|
||||
} else {
|
||||
await openShortRunUpgradesPicker(gameState);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import {
|
|||
getMajorityValue,
|
||||
makeEmptyPerksMap,
|
||||
sample,
|
||||
sumOfKeys,
|
||||
sumOfValues,
|
||||
} from "./game_utils";
|
||||
|
||||
describe("getMajorityValue", () => {
|
||||
|
@ -31,16 +31,16 @@ describe("sample", () => {
|
|||
});
|
||||
describe("sumOfKeys", () => {
|
||||
it("returns the sum of the keys of an array", () => {
|
||||
expect(sumOfKeys({ a: 1, b: 2 })).toEqual(3);
|
||||
expect(sumOfValues({ a: 1, b: 2 })).toEqual(3);
|
||||
});
|
||||
it("returns 0 for an empty object", () => {
|
||||
expect(sumOfKeys({})).toEqual(0);
|
||||
expect(sumOfValues({})).toEqual(0);
|
||||
});
|
||||
it("returns 0 for undefined", () => {
|
||||
expect(sumOfKeys(undefined)).toEqual(0);
|
||||
expect(sumOfValues(undefined)).toEqual(0);
|
||||
});
|
||||
it("returns 0 for null", () => {
|
||||
expect(sumOfKeys(null)).toEqual(0);
|
||||
expect(sumOfValues(null)).toEqual(0);
|
||||
});
|
||||
});
|
||||
describe("makeEmptyPerksMap", () => {
|
||||
|
|
|
@ -14,7 +14,7 @@ export function sample<T>(arr: T[]): T {
|
|||
return arr[Math.floor(arr.length * Math.random())];
|
||||
}
|
||||
|
||||
export function sumOfKeys(obj: { [key: string]: number } | undefined | null) {
|
||||
export function sumOfValues(obj: { [key: string]: number } | undefined | null) {
|
||||
if (!obj) return 0;
|
||||
return Object.values(obj)?.reduce((a, b) => a + b, 0) || 0;
|
||||
}
|
||||
|
|
|
@ -512,6 +512,36 @@
|
|||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>downgrade</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>perk_loss</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>pick_upgrade_title</name>
|
||||
<description/>
|
||||
|
|
|
@ -30,6 +30,8 @@
|
|||
"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.downgrade": "Downgrade :",
|
||||
"level_up.perk_loss": "Perk lost : ",
|
||||
"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)",
|
||||
|
|
|
@ -30,6 +30,8 @@
|
|||
"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.downgrade": "",
|
||||
"level_up.perk_loss": "",
|
||||
"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)",
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
defaultSounds,
|
||||
getPossibleUpgrades,
|
||||
makeEmptyPerksMap,
|
||||
sumOfKeys,
|
||||
sumOfValues,
|
||||
} from "./game_utils";
|
||||
import { dontOfferTooSoon, resetBalls } from "./gameStateMutators";
|
||||
import { isOptionOn } from "./options";
|
||||
|
@ -73,7 +73,7 @@ export function newGameState(params: RunParams): GameState {
|
|||
coinSize: 14,
|
||||
puckHeight: 20,
|
||||
totalScoreAtRunStart,
|
||||
isCreativeModeRun: sumOfKeys(perks) > 1,
|
||||
isCreativeModeRun: sumOfValues(perks) > 1,
|
||||
pauseUsesDuringRun: 0,
|
||||
keyboardPuckSpeed: 0,
|
||||
lastTick: performance.now(),
|
||||
|
@ -108,7 +108,7 @@ export function newGameState(params: RunParams): GameState {
|
|||
};
|
||||
resetBalls(gameState);
|
||||
|
||||
if (!sumOfKeys(gameState.perks)) {
|
||||
if (!sumOfValues(gameState.perks)) {
|
||||
const giftable = getPossibleUpgrades(gameState).filter((u) => u.giftable);
|
||||
const randomGift =
|
||||
(isOptionOn("easy") && "slow_down") ||
|
||||
|
|
|
@ -583,6 +583,7 @@ export const rawUpgrades = [
|
|||
giftable: false,
|
||||
id: "clairvoyant",
|
||||
max: 1,
|
||||
// TODO update for adventure mode
|
||||
name: t("upgrades.clairvoyant.name"),
|
||||
help: (lvl: number) => t("upgrades.clairvoyant.help"),
|
||||
fullHelp: t("upgrades.clairvoyant.fullHelp"),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue