mirror of
https://gitlab.com/lecarore/breakout71.git
synced 2025-04-22 13:06:15 -04:00
Updated soft reset perk to just keep lvl*10 % of combo
This commit is contained in:
parent
a3c66fcdea
commit
a1bf54af71
20 changed files with 4543 additions and 4445 deletions
122
src/asyncAlert.ts
Normal file
122
src/asyncAlert.ts
Normal file
|
@ -0,0 +1,122 @@
|
|||
import {t} from "./i18n/i18n";
|
||||
|
||||
export let alertsOpen = 0,
|
||||
closeModal: null | (() => void) = null;
|
||||
|
||||
export type AsyncAlertAction<t> = {
|
||||
text?: string;
|
||||
value?: t;
|
||||
help?: string;
|
||||
disabled?: boolean;
|
||||
icon?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
||||
export function asyncAlert<t>({
|
||||
title,
|
||||
text,
|
||||
actions,
|
||||
allowClose = true,
|
||||
textAfterButtons = "",
|
||||
actionsAsGrid = false,
|
||||
}: {
|
||||
title?: string;
|
||||
text?: string;
|
||||
actions?: AsyncAlertAction<t>[];
|
||||
textAfterButtons?: string;
|
||||
allowClose?: boolean;
|
||||
actionsAsGrid?: boolean;
|
||||
}): Promise<t | void> {
|
||||
alertsOpen++;
|
||||
return new Promise((resolve) => {
|
||||
const popupWrap = document.createElement("div");
|
||||
document.body.appendChild(popupWrap);
|
||||
popupWrap.className = "popup " + (actionsAsGrid ? "actionsAsGrid " : "");
|
||||
|
||||
function closeWithResult(value: t | undefined) {
|
||||
resolve(value);
|
||||
// Doing this async lets the menu scroll persist if it's shown a second time
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(popupWrap);
|
||||
});
|
||||
}
|
||||
|
||||
if (allowClose) {
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.title = t('play.close_modale_window_tooltip');
|
||||
closeButton.className = "close-modale";
|
||||
closeButton.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
closeWithResult(undefined);
|
||||
});
|
||||
closeModal = () => {
|
||||
closeWithResult(undefined);
|
||||
};
|
||||
popupWrap.appendChild(closeButton);
|
||||
}
|
||||
|
||||
const popup = document.createElement("div");
|
||||
|
||||
if (title) {
|
||||
const p = document.createElement("h2");
|
||||
p.innerHTML = title;
|
||||
popup.appendChild(p);
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const p = document.createElement("div");
|
||||
p.innerHTML = text;
|
||||
popup.appendChild(p);
|
||||
}
|
||||
|
||||
const buttons = document.createElement("section");
|
||||
popup.appendChild(buttons);
|
||||
|
||||
actions
|
||||
?.filter((i) => i)
|
||||
.forEach(({text, value, help, disabled, className = "", icon = ""}) => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
button.innerHTML = `
|
||||
${icon}
|
||||
<div>
|
||||
<strong>${text}</strong>
|
||||
<em>${help || ""}</em>
|
||||
</div>`;
|
||||
|
||||
if (disabled) {
|
||||
button.setAttribute("disabled", "disabled");
|
||||
} else {
|
||||
button.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
closeWithResult(value);
|
||||
});
|
||||
}
|
||||
button.className = className;
|
||||
buttons.appendChild(button);
|
||||
});
|
||||
|
||||
if (textAfterButtons) {
|
||||
const p = document.createElement("div");
|
||||
p.className = "textAfterButtons";
|
||||
p.innerHTML = textAfterButtons;
|
||||
popup.appendChild(p);
|
||||
}
|
||||
|
||||
popupWrap.appendChild(popup);
|
||||
(
|
||||
popup.querySelector("button:not([disabled])") as HTMLButtonElement
|
||||
)?.focus();
|
||||
}).then(
|
||||
(v: unknown) => {
|
||||
alertsOpen--;
|
||||
closeModal = null;
|
||||
return v as t | undefined;
|
||||
},
|
||||
() => {
|
||||
closeModal = null;
|
||||
alertsOpen--;
|
||||
},
|
||||
);
|
||||
}
|
69
src/combo.ts
69
src/combo.ts
|
@ -1,69 +0,0 @@
|
|||
import { GameState } from "./types";
|
||||
import { sounds } from "./sounds";
|
||||
|
||||
export function baseCombo(gameState: GameState) {
|
||||
return 1 + gameState.perks.base_combo * 3 + gameState.perks.smaller_puck * 5;
|
||||
}
|
||||
|
||||
export function resetCombo(
|
||||
gameState: GameState,
|
||||
x: number | undefined,
|
||||
y: number | undefined,
|
||||
) {
|
||||
const prev = gameState.combo;
|
||||
gameState.combo = baseCombo(gameState);
|
||||
if (!gameState.levelTime) {
|
||||
gameState.combo += gameState.perks.hot_start * 15;
|
||||
}
|
||||
if (prev > gameState.combo && gameState.perks.soft_reset) {
|
||||
gameState.combo += Math.floor(
|
||||
(prev - gameState.combo) / (1 + gameState.perks.soft_reset),
|
||||
);
|
||||
}
|
||||
const lost = Math.max(0, prev - gameState.combo);
|
||||
if (lost) {
|
||||
for (let i = 0; i < lost && i < 8; i++) {
|
||||
setTimeout(() => sounds.comboDecrease(), i * 100);
|
||||
}
|
||||
if (typeof x !== "undefined" && typeof y !== "undefined") {
|
||||
gameState.flashes.push({
|
||||
type: "text",
|
||||
text: "-" + lost,
|
||||
time: gameState.levelTime,
|
||||
color: "red",
|
||||
x: x,
|
||||
y: y,
|
||||
duration: 150,
|
||||
size: gameState.puckHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lost;
|
||||
}
|
||||
|
||||
export function decreaseCombo(
|
||||
gameState: GameState,
|
||||
by: number,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const prev = gameState.combo;
|
||||
gameState.combo = Math.max(baseCombo(gameState), gameState.combo - by);
|
||||
const lost = Math.max(0, prev - gameState.combo);
|
||||
|
||||
if (lost) {
|
||||
sounds.comboDecrease();
|
||||
if (typeof x !== "undefined" && typeof y !== "undefined") {
|
||||
gameState.flashes.push({
|
||||
type: "text",
|
||||
text: "-" + lost,
|
||||
time: gameState.levelTime,
|
||||
color: "red",
|
||||
x: x,
|
||||
y: y,
|
||||
duration: 300,
|
||||
size: gameState.puckHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
2539
src/game.ts
2539
src/game.ts
File diff suppressed because it is too large
Load diff
251
src/gameOver.ts
Normal file
251
src/gameOver.ts
Normal file
|
@ -0,0 +1,251 @@
|
|||
import {allLevels, appVersion, upgrades} from "./loadGameData";
|
||||
import {t} from "./i18n/i18n";
|
||||
import {RunHistoryItem} from "./types";
|
||||
import {gameState, pause, restart} from "./game";
|
||||
import {currentLevelInfo, findLast} from "./game_utils";
|
||||
import {getTotalScore} from "./settings";
|
||||
import {stopRecording} from "./recording";
|
||||
import {asyncAlert} from "./asyncAlert";
|
||||
|
||||
export function getUpgraderUnlockPoints() {
|
||||
let list = [] as { threshold: number; title: string }[];
|
||||
|
||||
upgrades.forEach((u) => {
|
||||
if (u.threshold) {
|
||||
list.push({
|
||||
threshold: u.threshold,
|
||||
title: u.name + ' ' + t('level_up.unlocked_perk'),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
allLevels.forEach((l) => {
|
||||
list.push({
|
||||
threshold: l.threshold,
|
||||
title: l.name + ' ' + t('level_up.unlocked_level'),
|
||||
});
|
||||
});
|
||||
|
||||
return list
|
||||
.filter((o) => o.threshold)
|
||||
.sort((a, b) => a.threshold - b.threshold);
|
||||
}
|
||||
|
||||
export function addToTotalPlayTime(ms: number) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"breakout_71_total_play_time",
|
||||
JSON.stringify(
|
||||
JSON.parse(localStorage.getItem("breakout_71_total_play_time") || "0") +
|
||||
ms,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
export function gameOver(title: string, intro: string) {
|
||||
if (!gameState.running) return;
|
||||
pause(true);
|
||||
stopRecording();
|
||||
addToTotalPlayTime(gameState.runStatistics.runTime);
|
||||
gameState.runStatistics.max_level = gameState.currentLevel + 1;
|
||||
|
||||
let animationDelay = -300;
|
||||
const getDelay = () => {
|
||||
animationDelay += 800;
|
||||
return "animation-delay:" + animationDelay + "ms;";
|
||||
};
|
||||
// unlocks
|
||||
let unlocksInfo = "";
|
||||
const endTs = getTotalScore();
|
||||
const startTs = endTs - gameState.score;
|
||||
const list = getUpgraderUnlockPoints();
|
||||
list
|
||||
.filter((u) => u.threshold > startTs && u.threshold < endTs)
|
||||
.forEach((u) => {
|
||||
unlocksInfo += `
|
||||
<p class="progress" >
|
||||
<span>${u.title}</span>
|
||||
<span class="progress_bar_part" style="${getDelay()}"></span>
|
||||
</p>
|
||||
`;
|
||||
});
|
||||
const previousUnlockAt =
|
||||
findLast(list, (u) => u.threshold <= endTs)?.threshold || 0;
|
||||
const nextUnlock = list.find((u) => u.threshold > endTs);
|
||||
|
||||
if (nextUnlock) {
|
||||
const total = nextUnlock?.threshold - previousUnlockAt;
|
||||
const done = endTs - previousUnlockAt;
|
||||
|
||||
intro += t('gameOver.next_unlock', {points: nextUnlock.threshold - endTs});
|
||||
|
||||
const scaleX = (done / total).toFixed(2);
|
||||
unlocksInfo += `
|
||||
<p class="progress" >
|
||||
<span>${nextUnlock.title}</span>
|
||||
<span style="transform: scale(${scaleX},1);${getDelay()}" class="progress_bar_part"></span>
|
||||
</p>
|
||||
|
||||
`;
|
||||
list
|
||||
.slice(list.indexOf(nextUnlock) + 1)
|
||||
.slice(0, 3)
|
||||
.forEach((u) => {
|
||||
unlocksInfo += `
|
||||
<p class="progress" >
|
||||
<span>${u.title}</span>
|
||||
</p>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
let unlockedItems = list.filter(
|
||||
(u) => u.threshold > startTs && u.threshold < endTs,
|
||||
);
|
||||
if (unlockedItems.length) {
|
||||
|
||||
unlocksInfo += `<p>${t('gameOver.unlocked_count', {count: unlockedItems.length})} ${unlockedItems.map((u) => u.title).join(", ")}</p>`;
|
||||
}
|
||||
|
||||
// Avoid the sad sound right as we restart a new games
|
||||
gameState.combo = 1;
|
||||
|
||||
asyncAlert({
|
||||
allowClose: true,
|
||||
title,
|
||||
text: `
|
||||
${gameState.isCreativeModeRun ? `<p>${t('gameOver.test_run')}</p> ` : ""}
|
||||
<p>${intro}</p>
|
||||
<p>${t('gameOver.cumulative_total', {startTs, endTs})}</p>
|
||||
${unlocksInfo}
|
||||
`,
|
||||
actions: [
|
||||
{
|
||||
value: null,
|
||||
text: t('gameOver.restart'),
|
||||
help: "",
|
||||
},
|
||||
],
|
||||
textAfterButtons: `<div id="level-recording-container"></div>
|
||||
${getHistograms()}
|
||||
`,
|
||||
}).then(() => restart({levelToAvoid: currentLevelInfo(gameState).name}));
|
||||
}
|
||||
|
||||
export function getHistograms() {
|
||||
let runStats = "";
|
||||
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);
|
||||
|
||||
runsHistory.push({
|
||||
...gameState.runStatistics,
|
||||
perks: gameState.perks,
|
||||
appVersion,
|
||||
});
|
||||
|
||||
// Generate some histogram
|
||||
if (!gameState.isCreativeModeRun)
|
||||
localStorage.setItem(
|
||||
"breakout_71_runs_history",
|
||||
JSON.stringify(runsHistory, null, 2),
|
||||
);
|
||||
|
||||
const makeHistogram = (
|
||||
title: string,
|
||||
getter: (hi: RunHistoryItem) => number,
|
||||
unit: string,
|
||||
) => {
|
||||
let values = runsHistory.map((h) => getter(h) || 0);
|
||||
let min = Math.min(...values);
|
||||
let max = Math.max(...values);
|
||||
// No point
|
||||
if (min === max) return "";
|
||||
if (max - min < 10) {
|
||||
// This is mostly useful for levels
|
||||
min = Math.max(0, max - 10);
|
||||
max = Math.max(max, min + 10);
|
||||
}
|
||||
// One bin per unique value, max 10
|
||||
const binsCount = Math.min(values.length, 10);
|
||||
if (binsCount < 3) return "";
|
||||
const bins = [] as number[];
|
||||
const binsTotal = [] as number[];
|
||||
for (let i = 0; i < binsCount; i++) {
|
||||
bins.push(0);
|
||||
binsTotal.push(0);
|
||||
}
|
||||
const binSize = (max - min) / bins.length;
|
||||
const binIndexOf = (v: number) =>
|
||||
Math.min(bins.length - 1, Math.floor((v - min) / binSize));
|
||||
values.forEach((v) => {
|
||||
if (isNaN(v)) return;
|
||||
const index = binIndexOf(v);
|
||||
bins[index]++;
|
||||
binsTotal[index] += v;
|
||||
});
|
||||
if (bins.filter((b) => b).length < 3) return "";
|
||||
const maxBin = Math.max(...bins);
|
||||
const lastValue = values[values.length - 1];
|
||||
const activeBin = binIndexOf(lastValue);
|
||||
|
||||
const bars = bins
|
||||
.map((v, vi) => {
|
||||
const style = `height: ${(v / maxBin) * 80}px`;
|
||||
return `<span class="${vi === activeBin ? "active" : ""}"><span style="${style}" title="${v} run${v > 1 ? "s" : ""} between ${Math.floor(min + vi * binSize)} and ${Math.floor(min + (vi + 1) * binSize)}${unit}"
|
||||
><span>${(!v && " ") || (vi == activeBin && lastValue + unit) || Math.round(binsTotal[vi] / v) + unit}</span></span></span>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `<h2 class="histogram-title">${title} : <strong>${lastValue}${unit}</strong></h2>
|
||||
<div class="histogram">${bars}</div>
|
||||
`;
|
||||
};
|
||||
|
||||
runStats += makeHistogram(t('gameOver.stats.total_score'), (r) => r.score, "");
|
||||
runStats += makeHistogram(t('gameOver.stats.catch_rate'),
|
||||
(r) => Math.round((r.score / r.coins_spawned) * 100),
|
||||
"%",
|
||||
);
|
||||
runStats += makeHistogram(t('gameOver.stats.bricks_broken'), (r) => r.bricks_broken, "");
|
||||
runStats += makeHistogram(
|
||||
t('gameOver.stats.bricks_per_minute'),
|
||||
(r) => Math.round((r.bricks_broken / r.runTime) * 1000 * 60),
|
||||
"",
|
||||
);
|
||||
runStats += makeHistogram(
|
||||
t('gameOver.stats.hit_rate'),
|
||||
(r) => Math.round((1 - r.misses / r.puck_bounces) * 100),
|
||||
"%",
|
||||
);
|
||||
runStats += makeHistogram(
|
||||
t('gameOver.stats.duration_per_level'),
|
||||
(r) => Math.round(r.runTime / 1000 / r.levelsPlayed),
|
||||
"s",
|
||||
);
|
||||
runStats += makeHistogram(t('gameOver.stats.level_reached'), (r) => r.levelsPlayed, "");
|
||||
runStats += makeHistogram(t('gameOver.stats.upgrades_applied'), (r) => r.upgrades_picked, "");
|
||||
runStats += makeHistogram(t('gameOver.stats.balls_lost'), (r) => r.balls_lost, "");
|
||||
runStats += makeHistogram(
|
||||
t('gameOver.stats.combo_avg'),
|
||||
(r) => Math.round(r.coins_spawned / r.bricks_broken),
|
||||
"",
|
||||
);
|
||||
runStats += makeHistogram(t('gameOver.stats.combo_max'), (r) => r.max_combo, "");
|
||||
|
||||
if (runStats) {
|
||||
runStats =
|
||||
`<p>${t('gameOver.stats.intro', {count: runsHistory.length - 1})}</p>` +
|
||||
runStats;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
return runStats;
|
||||
}
|
1178
src/gameStateMutators.ts
Normal file
1178
src/gameStateMutators.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,4 +1,5 @@
|
|||
import { PerkId, PerksMap, Upgrade } from "./types";
|
||||
import {Ball, GameState, PerkId, PerksMap} from "./types";
|
||||
import {icons, upgrades} from "./loadGameData";
|
||||
|
||||
export function getMajorityValue(arr: string[]): string {
|
||||
const count: { [k: string]: number } = {};
|
||||
|
@ -22,3 +23,79 @@ export const makeEmptyPerksMap = (upgrades: { id: PerkId }[]) => {
|
|||
upgrades.forEach((u) => (p[u.id] = 0));
|
||||
return p as PerksMap;
|
||||
};
|
||||
|
||||
export function brickCenterX(gameState: GameState, index: number) {
|
||||
return (
|
||||
gameState.offsetX +
|
||||
((index % gameState.gridSize) + 0.5) * gameState.brickWidth
|
||||
);
|
||||
}
|
||||
|
||||
export function brickCenterY(gameState: GameState, index: number) {
|
||||
return (Math.floor(index / gameState.gridSize) + 0.5) * gameState.brickWidth;
|
||||
}
|
||||
|
||||
export function getRowColIndex(gameState: GameState, row: number, col: number) {
|
||||
if (
|
||||
row < 0 ||
|
||||
col < 0 ||
|
||||
row >= gameState.gridSize ||
|
||||
col >= gameState.gridSize
|
||||
)
|
||||
return -1;
|
||||
return row * gameState.gridSize + col;
|
||||
}
|
||||
|
||||
export function getPossibleUpgrades(gameState: GameState) {
|
||||
return upgrades
|
||||
.filter((u) => gameState.totalScoreAtRunStart >= u.threshold)
|
||||
.filter((u) => !u?.requires || gameState.perks[u?.requires]);
|
||||
}
|
||||
|
||||
export function max_levels(gameState: GameState) {
|
||||
return 7 + gameState.perks.extra_levels;
|
||||
}
|
||||
|
||||
export function pickedUpgradesHTMl(gameState: GameState) {
|
||||
let list = "";
|
||||
for (let u of upgrades) {
|
||||
for (let i = 0; i < gameState.perks[u.id]; i++)
|
||||
list += icons["icon:" + u.id] + " ";
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export function currentLevelInfo(gameState: GameState) {
|
||||
return gameState.runLevels[
|
||||
gameState.currentLevel % gameState.runLevels.length
|
||||
];
|
||||
}
|
||||
|
||||
export function isTelekinesisActive(gameState: GameState, ball: Ball) {
|
||||
return gameState.perks.telekinesis && !ball.hitSinceBounce && ball.vy < 0;
|
||||
}
|
||||
|
||||
export function findLast<T>(
|
||||
arr: T[],
|
||||
predicate: (item: T, index: number, array: T[]) => boolean,
|
||||
) {
|
||||
let i = arr.length;
|
||||
while (--i)
|
||||
if (predicate(arr[i], i, arr)) {
|
||||
return arr[i];
|
||||
}
|
||||
}
|
||||
|
||||
export function distance2(
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
) {
|
||||
return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2);
|
||||
}
|
||||
|
||||
export function distanceBetween(
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
) {
|
||||
return Math.sqrt(distance2(a, b));
|
||||
}
|
|
@ -96,7 +96,7 @@
|
|||
"upgrades.ball_repulse_ball.help_plural": "Stronger repulsion force",
|
||||
"upgrades.ball_repulse_ball.name": "Personal space",
|
||||
"upgrades.base_combo.fullHelp": "Your combo (number of coins per bricks) normally starts at 1 at the beginning of the level, and resets to one when you bounce around without hitting anything. With this perk, the combo starts 3 points higher, so you'll always get at least 4 coins per brick. Whenever your combo reset, it will go back to 4 and not 1. Your ball will glitter a bit to indicate that its combo is higher than one.",
|
||||
"upgrades.base_combo.help": "Every brick drops at least {{coins}} coins.",
|
||||
"upgrades.base_combo.help": "Combo starts at {{coins}}.",
|
||||
"upgrades.base_combo.name": "+3 base combo",
|
||||
"upgrades.bigger_explosions.fullHelp": "The default explosion clears a 3x3 square, with this it becomes a 5x5 square, and the blow on the coins is also significantly stronger. The screen will flash after each explosion (except in basic mode)",
|
||||
"upgrades.bigger_explosions.help": "Bigger explosions",
|
||||
|
@ -125,7 +125,7 @@
|
|||
"upgrades.instant_upgrade.help": "-1 choice until run end.",
|
||||
"upgrades.instant_upgrade.name": "+2 upgrades now",
|
||||
"upgrades.left_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 left side . \n\nAs soon as your combo rises, the left 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 of the reset conditions are met.",
|
||||
"upgrades.left_is_lava.help": "More coins if you don't touch the left side.",
|
||||
"upgrades.left_is_lava.help": "+1 combo per brick broken, resets on left side hit",
|
||||
"upgrades.left_is_lava.name": "Avoid left side",
|
||||
"upgrades.metamorphosis.fullHelp": "With this perk, coins will be of the color of the brick they come from, and will color the first brick they touch in the same color. Coins spawn with the speed of the ball that broke them, which means you can aim a bit in the direction of the bricks you want to \"paint\".",
|
||||
"upgrades.metamorphosis.help": "Coins stain the bricks they touch",
|
||||
|
@ -137,7 +137,7 @@
|
|||
"upgrades.one_more_choice.help": "Further level ups will offer one more option in the list",
|
||||
"upgrades.one_more_choice.name": "+1 choice until run end",
|
||||
"upgrades.picky_eater.fullHelp": "Whenever you break a brick the same color as your ball, your combo increases by one. \nIf it's a different color, the ball takes that new color, but the combo resets.\nThe bricks with the right color will get a white border. \nOnce you get a combo higher than your minimum, the bricks of the wrong color will get a red halo. \nIf you have more than one ball, they all change color whenever one of them hits a brick.",
|
||||
"upgrades.picky_eater.help": "More coins if you break bricks color by color.",
|
||||
"upgrades.picky_eater.help": "+1 combo per brick broken, resets on ball color change",
|
||||
"upgrades.picky_eater.name": "Picky eater",
|
||||
"upgrades.pierce.fullHelp": "The ball normally bounces as soon as it touches something. With this perk, it will continue its trajectory for up to 3 bricks broken. \nAfter that, it will bounce on the 4th brick, and you'll need to touch the puck to reset the counter.",
|
||||
"upgrades.pierce.help": "Ball pierces {{count}} bricks after a puck bounce",
|
||||
|
@ -154,15 +154,15 @@
|
|||
"upgrades.respawn.help_plural": "More bricks can re-spawn",
|
||||
"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": "More coins if you don't touch the right side.",
|
||||
"upgrades.right_is_lava.help": "+1 combo per brick broken, resets on right side hit",
|
||||
"upgrades.right_is_lava.name": "Avoid right side",
|
||||
"upgrades.sapper.fullHelp": "Instead of just disappearing, the first brick you break will be replaced by a bomb brick. \n\nBouncing the ball on the puck re-arms the effect. \n\nLeveling-up this perk will allow you to place more bombs.\n\nRemember that bombs impact the velocity of nearby coins, so too many explosions could make it hard to catch the fruits of your hard work.",
|
||||
"upgrades.sapper.help": "The first brick broken becomes a bomb.",
|
||||
"upgrades.sapper.help_plural": "The first {{lvl}} bricks broken become bombs.",
|
||||
"upgrades.sapper.name": "Sapper",
|
||||
"upgrades.skip_last.fullHelp": "You need to break all bricks to go to the next level. However, it can be hard to get the last ones. \n\nClearing a level early brings extra choices when upgrading. Never missing the bricks is also very beneficial. \n\nSo if you find it difficult to break the last bricks, getting this perk a few time can help.",
|
||||
"upgrades.skip_last.help": "The last brick will self-destruct.",
|
||||
"upgrades.skip_last.help_plural": "The last {{lvl}} bricks will self-destruct.",
|
||||
"upgrades.skip_last.help": "The last brick will explode.",
|
||||
"upgrades.skip_last.help_plural": "The last {{lvl}} bricks will explode.",
|
||||
"upgrades.skip_last.name": "Easy Cleanup",
|
||||
"upgrades.slow_down.fullHelp": "The ball starts relatively slow, but every level of your run it will start a bit faster. \n\nIt will also accelerate if you spend a lot of time in one level. \n\nThis perk makes it more manageable. \n\nYou can get it at the start every time by enabling kid mode in the menu.",
|
||||
"upgrades.slow_down.help": "Ball moves more slowly",
|
||||
|
@ -171,8 +171,8 @@
|
|||
"upgrades.smaller_puck.help": "Also gives +5 base combo",
|
||||
"upgrades.smaller_puck.help_plural": "Even smaller puck and higher base combo",
|
||||
"upgrades.smaller_puck.name": "Smaller puck",
|
||||
"upgrades.soft_reset.fullHelp": "The combo normally climbs every time you break a brick. This will sometimes cancel that climb, but also limit the impact of a combo reset.",
|
||||
"upgrades.soft_reset.help": "Combo grows slower but resets less",
|
||||
"upgrades.soft_reset.fullHelp": "Limit the impact of a combo reset.",
|
||||
"upgrades.soft_reset.help": "Combo resets keeps {{percent}}%",
|
||||
"upgrades.soft_reset.name": "Soft reset",
|
||||
"upgrades.streak_shots.fullHelp": "Every time you break a brick, your combo (number of coins per bricks) increases by one. \n\nHowever, as soon as the ball touches your puck, the combo is reset to its default value, and you'll just get one coin per brick.\n\nOnce your combo rises above the base value, your puck will become red to remind you that it will destroy your combo to touch it with the ball.\n\nThis can stack with other combo related perks, the combo will rise faster but reset more easily as any of the conditions is enough to reset it. ",
|
||||
"upgrades.streak_shots.help": "More coins if you break many bricks at once.",
|
||||
|
|
|
@ -96,7 +96,7 @@
|
|||
"upgrades.ball_repulse_ball.help_plural": "Force de répulsion plus forte",
|
||||
"upgrades.ball_repulse_ball.name": "Vol en formation",
|
||||
"upgrades.base_combo.fullHelp": "Votre combo (nombre de pièces par brique) commence normalement à 1 au début du niveau et revient à 1 lorsque vous rebondissez sans rien toucher. Avec cette caractéristique, le combo commence 3 points plus haut, ce qui fait que vous obtiendrez toujours au moins 4 pièces par brique. Lorsque votre combo est réinitialisé, il revient à 4 et non à 1. Votre balle scintillera un peu pour indiquer que son combo est supérieur à 1.",
|
||||
"upgrades.base_combo.help": "Chaque brique produit au moins {{coins}} pièces.",
|
||||
"upgrades.base_combo.help": "Le combo commence à {{coins}}.",
|
||||
"upgrades.base_combo.name": "Combo +3",
|
||||
"upgrades.bigger_explosions.fullHelp": "L'explosion par défaut efface un carré de 3x3 briques, avec cette amélioration un carré de 5x5. Le vent soufflant les pièces est également beaucoup plus fort. L'écran clignotera un peu après chaque explosion (sauf en mode graphismes basiques).",
|
||||
"upgrades.bigger_explosions.help": "Explosions plus violentes",
|
||||
|
@ -109,7 +109,7 @@
|
|||
"upgrades.coin_magnet.help_plural": "Effet plus marqué sur les pièces",
|
||||
"upgrades.coin_magnet.name": "Aimant pour pièces",
|
||||
"upgrades.compound_interest.fullHelp": "Votre combo augmentera d'une unité à chaque fois que vous casserez une brique, générant de plus en plus de pièces à chaque fois que vous casserez une brique. Veillez cependant à attraper chacune de ces pièces avec votre palet, car toute pièce perdue remettra votre combo à zéro. \n \nSi votre combinaison est supérieure au minimum, une ligne rouge s'affichera au bas de la zone de jeu pour vous le rappeler que les pièces ne doivent pas aller à cet endroit.\n\nCet avantage se combine avec d'autres avantages de combo, le combo augmentera plus rapidement mais se réinitialisera plus souvent.",
|
||||
"upgrades.compound_interest.help": "Attrapez toutes les pièces pour en avoir plus",
|
||||
"upgrades.compound_interest.help": "+1 combo par brique cassée, remise à zéro quand une pièce est perdu",
|
||||
"upgrades.compound_interest.name": "Intérêts",
|
||||
"upgrades.extra_levels.fullHelp": "La partie dure normalement 7 niveaux, après quoi le jeu est terminé et le score que vous avez atteint est votre score de partie.\n\nChoisir cette amélioration vous permet de prolonger la partie d'un niveau. Les derniers niveaux sont souvent ceux où vous faites le plus de points, la différence peut donc être spectaculaire.",
|
||||
"upgrades.extra_levels.help": "Jouer {{count}} niveaux au lieu de 7",
|
||||
|
@ -171,8 +171,8 @@
|
|||
"upgrades.smaller_puck.help": "Donne aussi +5 combo",
|
||||
"upgrades.smaller_puck.help_plural": "Palet encore plus petit et combinaison de base plus élevée",
|
||||
"upgrades.smaller_puck.name": "Palet plus petit",
|
||||
"upgrades.soft_reset.fullHelp": "Le combo monte normalement à chaque fois que vous cassez une brique. Ceci annulera parfois cette montée, mais limitera également l'impact d'une réinitialisation du combo.",
|
||||
"upgrades.soft_reset.help": "Le combo croît plus lentement mais se réinitialise moins",
|
||||
"upgrades.soft_reset.fullHelp": "Limite l'impact d'une réinitialisation du combo.",
|
||||
"upgrades.soft_reset.help": "La remise à zéro du combo conserve {{percent}}% des points",
|
||||
"upgrades.soft_reset.name": "Réinitialisation progressive",
|
||||
"upgrades.streak_shots.fullHelp": "Chaque fois que vous cassez une brique, votre combo (nombre de pièces par brique) augmente d'une unité. Cependant, dès que la balle touche votre palet, le combo est remis à sa valeur par défaut, et vous n'obtiendrez qu'une seule pièce par brique.\n\nUne fois que votre combinaison dépasse la valeur de base, votre palet devient rouge pour vous rappeler que le fait de le toucher avec la balle détruira votre combinaison.\n\nCela peut se cumuler avec d'autres avantages liés au combo, le combo augmentera plus rapidement mais se réinitialisera plus facilement car n'importe laquelle des conditions suffit à le réinitialiser.",
|
||||
"upgrades.streak_shots.help": "Plus de pièces si vous cassez plusieurs briques à la fois.",
|
||||
|
|
104
src/newGameState.ts
Normal file
104
src/newGameState.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
import {GameState, RunParams} from "./types";
|
||||
import {getTotalScore} from "./settings";
|
||||
import {allLevels, upgrades} from "./loadGameData";
|
||||
import {getPossibleUpgrades, makeEmptyPerksMap, sumOfKeys} from "./game_utils";
|
||||
import {dontOfferTooSoon, resetBalls} from "./gameStateMutators";
|
||||
import {isOptionOn} from "./options";
|
||||
|
||||
export function newGameState(params: RunParams): GameState {
|
||||
const totalScoreAtRunStart = getTotalScore();
|
||||
const firstLevel = params?.level
|
||||
? allLevels.filter((l) => l.name === params?.level)
|
||||
: [];
|
||||
|
||||
const restInRandomOrder = allLevels
|
||||
.filter((l) => totalScoreAtRunStart >= l.threshold)
|
||||
.filter((l) => l.name !== params?.level)
|
||||
.filter((l) => l.name !== params?.levelToAvoid)
|
||||
.sort(() => Math.random() - 0.5);
|
||||
|
||||
const runLevels = firstLevel.concat(
|
||||
restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey),
|
||||
);
|
||||
|
||||
const perks = {...makeEmptyPerksMap(upgrades), ...(params?.perks || {})};
|
||||
|
||||
const gameState: GameState = {
|
||||
runLevels,
|
||||
currentLevel: 0,
|
||||
perks,
|
||||
puckWidth: 200,
|
||||
baseSpeed: 12,
|
||||
combo: 1,
|
||||
gridSize: 12,
|
||||
running: false,
|
||||
puckPosition: 400,
|
||||
pauseTimeout: null,
|
||||
canvasWidth: 0,
|
||||
canvasHeight: 0,
|
||||
offsetX: 0,
|
||||
offsetXRoundedDown: 0,
|
||||
gameZoneWidth: 0,
|
||||
gameZoneWidthRoundedUp: 0,
|
||||
gameZoneHeight: 0,
|
||||
brickWidth: 0,
|
||||
score: 0,
|
||||
lastScoreIncrease: -1000,
|
||||
lastExplosion: -1000,
|
||||
highScore: parseFloat(localStorage.getItem("breakout-3-hs") || "0"),
|
||||
balls: [],
|
||||
ballsColor: "white",
|
||||
bricks: [],
|
||||
flashes: [],
|
||||
coins: [],
|
||||
levelStartScore: 0,
|
||||
levelMisses: 0,
|
||||
levelSpawnedCoins: 0,
|
||||
lastPlayedCoinGrab: 0,
|
||||
MAX_COINS: 400,
|
||||
MAX_PARTICLES: 600,
|
||||
puckColor: "#FFF",
|
||||
ballSize: 20,
|
||||
coinSize: 14,
|
||||
puckHeight: 20,
|
||||
totalScoreAtRunStart,
|
||||
isCreativeModeRun: sumOfKeys(perks) > 1,
|
||||
pauseUsesDuringRun: 0,
|
||||
keyboardPuckSpeed: 0,
|
||||
lastTick: performance.now(),
|
||||
lastTickDown: 0,
|
||||
runStatistics: {
|
||||
started: Date.now(),
|
||||
levelsPlayed: 0,
|
||||
runTime: 0,
|
||||
coins_spawned: 0,
|
||||
score: 0,
|
||||
bricks_broken: 0,
|
||||
misses: 0,
|
||||
balls_lost: 0,
|
||||
puck_bounces: 0,
|
||||
upgrades_picked: 1,
|
||||
max_combo: 1,
|
||||
max_level: 0,
|
||||
},
|
||||
lastOffered: {},
|
||||
levelTime: 0,
|
||||
autoCleanUses: 0,
|
||||
};
|
||||
resetBalls(gameState);
|
||||
|
||||
if (!sumOfKeys(gameState.perks)) {
|
||||
const giftable = getPossibleUpgrades(gameState).filter((u) => u.giftable);
|
||||
const randomGift =
|
||||
(isOptionOn("easy") && "slow_down") ||
|
||||
giftable[Math.floor(Math.random() * giftable.length)].id;
|
||||
perks[randomGift] = 1;
|
||||
dontOfferTooSoon(gameState, randomGift);
|
||||
}
|
||||
for (let perk of upgrades) {
|
||||
if (gameState.perks[perk.id]) {
|
||||
dontOfferTooSoon(gameState, perk.id);
|
||||
}
|
||||
}
|
||||
return gameState;
|
||||
}
|
|
@ -1,71 +1,54 @@
|
|||
import {fitSize} from "./game";
|
||||
import {t} from "./i18n/i18n";
|
||||
|
||||
import {OptionDef, OptionId} from "./types";
|
||||
import {getSettingValue, setSettingValue} from "./settings";
|
||||
|
||||
export const options = {
|
||||
sound: {
|
||||
default: true,
|
||||
name: t('main_menu.sounds'),
|
||||
help: t('main_menu.sounds_help'),
|
||||
afterChange: () => {},
|
||||
disabled: () => false,
|
||||
},
|
||||
"mobile-mode": {
|
||||
default: window.innerHeight > window.innerWidth,
|
||||
name: t('main_menu.mobile'),
|
||||
help: t('main_menu.mobile_help'),
|
||||
afterChange() {
|
||||
fitSize();
|
||||
sound: {
|
||||
default: true,
|
||||
name: t('main_menu.sounds'),
|
||||
help: t('main_menu.sounds_help'),
|
||||
disabled: () => false,
|
||||
},
|
||||
disabled: () => false,
|
||||
},
|
||||
basic: {
|
||||
default: false,
|
||||
name: t('main_menu.basic'),
|
||||
help: t('main_menu.basic_help'),
|
||||
afterChange: () => {},
|
||||
disabled: () => false,
|
||||
},
|
||||
pointerLock: {
|
||||
default: false,
|
||||
name: t('main_menu.pointer_lock'),
|
||||
help: t('main_menu.pointer_lock_help'),
|
||||
afterChange: () => {},
|
||||
disabled: () => !document.body.requestPointerLock,
|
||||
},
|
||||
easy: {
|
||||
default: false,
|
||||
name: t('main_menu.kid'),
|
||||
help: t('main_menu.kid_help'),
|
||||
afterChange: () => {},
|
||||
disabled: () => false,
|
||||
},
|
||||
// Could not get the sharing to work without loading androidx and all the modern android things so for now I'll just disable sharing in the android app
|
||||
record: {
|
||||
default: false,
|
||||
name: t('main_menu.record'),
|
||||
help: t('main_menu.record_help'),
|
||||
afterChange: () => {},
|
||||
disabled() {
|
||||
return window.location.search.includes("isInWebView=true");
|
||||
"mobile-mode": {
|
||||
default: window.innerHeight > window.innerWidth,
|
||||
name: t('main_menu.mobile'),
|
||||
help: t('main_menu.mobile_help'),
|
||||
disabled: () => false,
|
||||
},
|
||||
basic: {
|
||||
default: false,
|
||||
name: t('main_menu.basic'),
|
||||
help: t('main_menu.basic_help'),
|
||||
disabled: () => false,
|
||||
},
|
||||
pointerLock: {
|
||||
default: false,
|
||||
name: t('main_menu.pointer_lock'),
|
||||
help: t('main_menu.pointer_lock_help'),
|
||||
disabled: () => !document.body.requestPointerLock,
|
||||
},
|
||||
easy: {
|
||||
default: false,
|
||||
name: t('main_menu.kid'),
|
||||
help: t('main_menu.kid_help'),
|
||||
disabled: () => false,
|
||||
},
|
||||
// Could not get the sharing to work without loading androidx and all the modern android things so for now I'll just disable sharing in the android app
|
||||
record: {
|
||||
default: false,
|
||||
name: t('main_menu.record'),
|
||||
help: t('main_menu.record_help'),
|
||||
disabled() {
|
||||
return window.location.search.includes("isInWebView=true");
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies { [k: string]: OptionDef };
|
||||
|
||||
export type OptionDef = {
|
||||
default: boolean;
|
||||
name: string;
|
||||
help: string;
|
||||
disabled: () => boolean;
|
||||
afterChange: () => void;
|
||||
};
|
||||
export type OptionId = keyof typeof options;
|
||||
|
||||
export function isOptionOn(key: OptionId) {
|
||||
return getSettingValue(key, options[key]?.default)
|
||||
return getSettingValue("breakout-settings-enable-" + key, options[key]?.default)
|
||||
}
|
||||
|
||||
export function toggleOption(key: OptionId) {
|
||||
setSettingValue(key, !isOptionOn(key))
|
||||
options[key].afterChange();
|
||||
setSettingValue("breakout-settings-enable-" +key, !isOptionOn(key))
|
||||
}
|
|
@ -255,9 +255,9 @@ export const rawUpgrades = [
|
|||
threshold: 18000,
|
||||
giftable: false,
|
||||
id: "soft_reset",
|
||||
max: 2,
|
||||
max: 9,
|
||||
name: t('upgrades.soft_reset.name'),
|
||||
help: (lvl: number) => t('upgrades.soft_reset.help'),
|
||||
help: (lvl: number) => t('upgrades.soft_reset.help',{percent:10*lvl}),
|
||||
fullHelp: t('upgrades.soft_reset.fullHelp'),
|
||||
|
||||
|
||||
|
|
168
src/recording.ts
Normal file
168
src/recording.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
import {gameCanvas} from "./render";
|
||||
import {max_levels} from "./game_utils";
|
||||
import {getAudioRecordingTrack} from "./sounds";
|
||||
import {t} from "./i18n/i18n";
|
||||
import {GameState} from "./types";
|
||||
import {isOptionOn} from "./options";
|
||||
|
||||
let mediaRecorder: MediaRecorder | null,
|
||||
captureStream: MediaStream,
|
||||
captureTrack: CanvasCaptureMediaStreamTrack,
|
||||
recordCanvas: HTMLCanvasElement,
|
||||
recordCanvasCtx: CanvasRenderingContext2D;
|
||||
|
||||
export function recordOneFrame(gameState:GameState) {
|
||||
if (!isOptionOn("record")) {
|
||||
return;
|
||||
}
|
||||
if (!gameState.running) return;
|
||||
if (!captureStream) return;
|
||||
drawMainCanvasOnSmallCanvas(gameState);
|
||||
if (captureTrack?.requestFrame) {
|
||||
captureTrack?.requestFrame();
|
||||
} else if (captureStream?.requestFrame) {
|
||||
captureStream.requestFrame();
|
||||
}
|
||||
}
|
||||
|
||||
export function drawMainCanvasOnSmallCanvas(gameState:GameState) {
|
||||
if (!recordCanvasCtx) return;
|
||||
recordCanvasCtx.drawImage(
|
||||
gameCanvas,
|
||||
gameState.offsetXRoundedDown,
|
||||
0,
|
||||
gameState.gameZoneWidthRoundedUp,
|
||||
gameState.gameZoneHeight,
|
||||
0,
|
||||
0,
|
||||
recordCanvas.width,
|
||||
recordCanvas.height,
|
||||
);
|
||||
|
||||
// Here we don't use drawText as we don't want to cache a picture for each distinct value of score
|
||||
recordCanvasCtx.fillStyle = "#FFF";
|
||||
recordCanvasCtx.textBaseline = "top";
|
||||
recordCanvasCtx.font = "12px monospace";
|
||||
recordCanvasCtx.textAlign = "right";
|
||||
recordCanvasCtx.fillText(
|
||||
gameState.score.toString(),
|
||||
recordCanvas.width - 12,
|
||||
12,
|
||||
);
|
||||
|
||||
recordCanvasCtx.textAlign = "left";
|
||||
recordCanvasCtx.fillText(
|
||||
"Level " + (gameState.currentLevel + 1) + "/" + max_levels(gameState),
|
||||
12,
|
||||
12,
|
||||
);
|
||||
}
|
||||
|
||||
export function startRecordingGame(gameState:GameState) {
|
||||
if (!isOptionOn("record")) {
|
||||
return;
|
||||
}
|
||||
if (mediaRecorder) return;
|
||||
if (!recordCanvas) {
|
||||
// Smaller canvas with fewer details
|
||||
recordCanvas = document.createElement("canvas");
|
||||
recordCanvasCtx = recordCanvas.getContext("2d", {
|
||||
antialias: false,
|
||||
alpha: false,
|
||||
}) as CanvasRenderingContext2D;
|
||||
|
||||
captureStream = recordCanvas.captureStream(0);
|
||||
captureTrack =
|
||||
captureStream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack;
|
||||
|
||||
const track = getAudioRecordingTrack();
|
||||
if (track) {
|
||||
captureStream.addTrack(track.stream.getAudioTracks()[0]);
|
||||
}
|
||||
}
|
||||
|
||||
recordCanvas.width = gameState.gameZoneWidthRoundedUp;
|
||||
recordCanvas.height = gameState.gameZoneHeight;
|
||||
|
||||
// drawMainCanvasOnSmallCanvas()
|
||||
const recordedChunks: Blob[] = [];
|
||||
|
||||
const instance = new MediaRecorder(captureStream, {
|
||||
videoBitsPerSecond: 3500000,
|
||||
});
|
||||
mediaRecorder = instance;
|
||||
instance.start();
|
||||
mediaRecorder.pause();
|
||||
instance.ondataavailable = function (event) {
|
||||
recordedChunks.push(event.data);
|
||||
};
|
||||
|
||||
instance.onstop = async function () {
|
||||
let targetDiv: HTMLElement | null;
|
||||
let blob = new Blob(recordedChunks, {type: "video/webm"});
|
||||
if (blob.size < 200000) return; // under 0.2MB, probably bugged out or pointlessly short
|
||||
|
||||
while (
|
||||
!(targetDiv = document.getElementById("level-recording-container"))
|
||||
) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
const video = document.createElement("video");
|
||||
video.autoplay = true;
|
||||
video.controls = false;
|
||||
video.disablePictureInPicture = true;
|
||||
video.disableRemotePlayback = true;
|
||||
video.width = recordCanvas.width;
|
||||
video.height = recordCanvas.height;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.src = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.download = captureFileName("webm");
|
||||
a.target = "_blank";
|
||||
a.href = video.src;
|
||||
a.textContent = t('main_menu.record_download', {
|
||||
size: (blob.size / 1000000).toFixed(2)
|
||||
});
|
||||
targetDiv.appendChild(video);
|
||||
targetDiv.appendChild(a);
|
||||
};
|
||||
}
|
||||
|
||||
export function pauseRecording( ) {
|
||||
if (!isOptionOn("record")) {
|
||||
return;
|
||||
}
|
||||
if (mediaRecorder?.state === "recording") {
|
||||
mediaRecorder?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
export function resumeRecording() {
|
||||
if (!isOptionOn("record")) {
|
||||
return;
|
||||
}
|
||||
if (mediaRecorder?.state === "paused") {
|
||||
mediaRecorder.resume();
|
||||
}
|
||||
}
|
||||
|
||||
export function stopRecording() {
|
||||
if (!isOptionOn("record")) {
|
||||
return;
|
||||
}
|
||||
if (!mediaRecorder) return;
|
||||
mediaRecorder?.stop();
|
||||
mediaRecorder = null;
|
||||
}
|
||||
|
||||
export function captureFileName(ext = "webm") {
|
||||
return (
|
||||
"breakout-71-capture-" +
|
||||
new Date().toISOString().replace(/[^0-9\-]+/gi, "-") +
|
||||
"." +
|
||||
ext
|
||||
);
|
||||
}
|
717
src/render.ts
Normal file
717
src/render.ts
Normal file
|
@ -0,0 +1,717 @@
|
|||
import {baseCombo} from "./gameStateMutators";
|
||||
import {brickCenterX, brickCenterY, currentLevelInfo, isTelekinesisActive, max_levels} from "./game_utils";
|
||||
import {colorString, GameState} from "./types";
|
||||
import {t} from "./i18n/i18n";
|
||||
import {gameState} from "./game";
|
||||
import {isOptionOn} from "./options";
|
||||
|
||||
export const gameCanvas = document.getElementById("game") as HTMLCanvasElement;
|
||||
export const ctx = gameCanvas.getContext("2d", {
|
||||
alpha: false,
|
||||
}) as CanvasRenderingContext2D;
|
||||
export const bombSVG = document.createElement("img");
|
||||
export const background = document.createElement("img");
|
||||
export const backgroundCanvas = document.createElement("canvas");
|
||||
|
||||
export function render(gameState: GameState) {
|
||||
|
||||
const level = currentLevelInfo(gameState);
|
||||
const {width, height} = gameCanvas;
|
||||
if (!width || !height) return;
|
||||
|
||||
if (gameState.currentLevel || gameState.levelTime) {
|
||||
menuLabel.innerText = t('play.current_lvl', {
|
||||
level: gameState.currentLevel + 1,
|
||||
max: max_levels(gameState)
|
||||
});
|
||||
} else {
|
||||
menuLabel.innerText = t('play.menu_label')
|
||||
}
|
||||
scoreDisplay.innerText = `$${gameState.score}`;
|
||||
|
||||
scoreDisplay.className =
|
||||
gameState.lastScoreIncrease > gameState.levelTime - 500 ? "active" : "";
|
||||
|
||||
// Clear
|
||||
if (!isOptionOn("basic") && !level.color && level.svg) {
|
||||
// Without this the light trails everything
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
ctx.globalAlpha = 0.6;
|
||||
gameState.coins.forEach((coin) => {
|
||||
if (!coin.destroyed)
|
||||
drawFuzzyBall(ctx, coin.color, gameState.coinSize * 2, coin.x, coin.y);
|
||||
});
|
||||
gameState.balls.forEach((ball) => {
|
||||
drawFuzzyBall(
|
||||
ctx,
|
||||
gameState.ballsColor,
|
||||
gameState.ballSize * 2,
|
||||
ball.x,
|
||||
ball.y,
|
||||
);
|
||||
});
|
||||
ctx.globalAlpha = 0.5;
|
||||
gameState.bricks.forEach((color, index) => {
|
||||
if (!color) return;
|
||||
const x = brickCenterX(gameState, index),
|
||||
y = brickCenterY(gameState, index);
|
||||
drawFuzzyBall(
|
||||
ctx,
|
||||
color == "black" ? "#666" : color,
|
||||
gameState.brickWidth,
|
||||
x,
|
||||
y,
|
||||
);
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
gameState.flashes.forEach((flash) => {
|
||||
const {x, y, time, color, size, type, duration} = flash;
|
||||
const elapsed = gameState.levelTime - time;
|
||||
ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
|
||||
if (type === "ball") {
|
||||
drawFuzzyBall(ctx, color, size, x, y);
|
||||
}
|
||||
if (type === "particle") {
|
||||
drawFuzzyBall(ctx, color, size * 3, x, y);
|
||||
}
|
||||
});
|
||||
// Decides how brights the bg black parts can get
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.globalCompositeOperation = "multiply";
|
||||
ctx.fillStyle = "black";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
// Decides how dark the background black parts are when lit (1=black)
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.globalCompositeOperation = "multiply";
|
||||
if (level.svg && background.width && background.complete) {
|
||||
if (backgroundCanvas.title !== level.name) {
|
||||
backgroundCanvas.title = level.name;
|
||||
backgroundCanvas.width = gameState.canvasWidth;
|
||||
backgroundCanvas.height = gameState.canvasHeight;
|
||||
const bgctx = backgroundCanvas.getContext(
|
||||
"2d",
|
||||
) as CanvasRenderingContext2D;
|
||||
bgctx.fillStyle = level.color || "#000";
|
||||
bgctx.fillRect(0, 0, gameState.canvasWidth, gameState.canvasHeight);
|
||||
const pattern = ctx.createPattern(background, "repeat");
|
||||
if (pattern) {
|
||||
bgctx.fillStyle = pattern;
|
||||
bgctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.drawImage(backgroundCanvas, 0, 0);
|
||||
} else {
|
||||
// Background not loaded yes
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
} else {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = level.color || "#000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
gameState.flashes.forEach((flash) => {
|
||||
const {x, y, time, color, size, type, duration} = flash;
|
||||
const elapsed = gameState.levelTime - time;
|
||||
ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
|
||||
if (type === "particle") {
|
||||
drawBall(ctx, color, size, x, y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
const lastExplosionDelay = Date.now() - gameState.lastExplosion + 5;
|
||||
const shaked = lastExplosionDelay < 200 && !isOptionOn('basic');
|
||||
if (shaked) {
|
||||
const amplitude =
|
||||
((gameState.perks.bigger_explosions + 1) * 50) / lastExplosionDelay;
|
||||
ctx.translate(
|
||||
Math.sin(Date.now()) * amplitude,
|
||||
Math.sin(Date.now() + 36) * amplitude,
|
||||
);
|
||||
}
|
||||
if (gameState.perks.bigger_explosions && !isOptionOn('basic')) {
|
||||
if (shaked) {
|
||||
gameCanvas.style.filter = 'brightness(' + (1 + 100 / (1 + lastExplosionDelay)) + ')';
|
||||
} else {
|
||||
gameCanvas.style.filter = ''
|
||||
}
|
||||
}
|
||||
// Coins
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
gameState.coins.forEach((coin) => {
|
||||
if (!coin.destroyed) {
|
||||
ctx.globalCompositeOperation =
|
||||
coin.color === "gold" || level.color ? "source-over" : "screen";
|
||||
drawCoin(
|
||||
ctx,
|
||||
coin.color,
|
||||
coin.size,
|
||||
coin.x,
|
||||
coin.y,
|
||||
level.color || "black",
|
||||
coin.a,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Black shadow around balls
|
||||
if (!isOptionOn("basic")) {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.globalAlpha = Math.min(0.8, gameState.coins.length / 20);
|
||||
gameState.balls.forEach((ball) => {
|
||||
drawBall(
|
||||
ctx,
|
||||
level.color || "#000",
|
||||
gameState.ballSize * 6,
|
||||
ball.x,
|
||||
ball.y,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
renderAllBricks();
|
||||
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
gameState.flashes = gameState.flashes.filter(
|
||||
(f) => gameState.levelTime - f.time < f.duration && !f.destroyed,
|
||||
);
|
||||
|
||||
gameState.flashes.forEach((flash) => {
|
||||
const {x, y, time, color, size, type, duration} = flash;
|
||||
const elapsed = gameState.levelTime - time;
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2));
|
||||
if (type === "text") {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
drawText(ctx, flash.text, color, size, x, y - elapsed / 10);
|
||||
} else if (type === "particle") {
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
drawBall(ctx, color, size, x, y);
|
||||
drawFuzzyBall(ctx, color, size, x, y);
|
||||
}
|
||||
});
|
||||
|
||||
if (gameState.perks.extra_life) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = gameState.puckColor;
|
||||
for (let i = 0; i < gameState.perks.extra_life; i++) {
|
||||
ctx.fillRect(
|
||||
gameState.offsetXRoundedDown,
|
||||
gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i,
|
||||
gameState.gameZoneWidthRoundedUp,
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
gameState.balls.forEach((ball) => {
|
||||
// The white border around is to distinguish colored balls from coins/bg
|
||||
drawBall(
|
||||
ctx,
|
||||
gameState.ballsColor,
|
||||
gameState.ballSize,
|
||||
ball.x,
|
||||
ball.y,
|
||||
gameState.puckColor,
|
||||
);
|
||||
|
||||
if (isTelekinesisActive(gameState, ball)) {
|
||||
ctx.strokeStyle = gameState.puckColor;
|
||||
ctx.beginPath();
|
||||
ctx.bezierCurveTo(
|
||||
gameState.puckPosition,
|
||||
gameState.gameZoneHeight,
|
||||
gameState.puckPosition,
|
||||
ball.y,
|
||||
ball.x,
|
||||
ball.y,
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
// The puck
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
if (gameState.perks.streak_shots && gameState.combo > baseCombo(gameState)) {
|
||||
drawPuck(ctx, "red", gameState.puckWidth, gameState.puckHeight, -2);
|
||||
}
|
||||
drawPuck(ctx, gameState.puckColor, gameState.puckWidth, gameState.puckHeight);
|
||||
|
||||
if (gameState.combo > 1) {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
const comboText = "x " + gameState.combo;
|
||||
const comboTextWidth = (comboText.length * gameState.puckHeight) / 1.8;
|
||||
const totalWidth = comboTextWidth + gameState.coinSize * 2;
|
||||
const left = gameState.puckPosition - totalWidth / 2;
|
||||
if (totalWidth < gameState.puckWidth) {
|
||||
drawCoin(
|
||||
ctx,
|
||||
"gold",
|
||||
gameState.coinSize,
|
||||
left + gameState.coinSize / 2,
|
||||
gameState.gameZoneHeight - gameState.puckHeight / 2,
|
||||
gameState.puckColor,
|
||||
0,
|
||||
);
|
||||
drawText(
|
||||
ctx,
|
||||
comboText,
|
||||
"#000",
|
||||
gameState.puckHeight,
|
||||
left + gameState.coinSize * 1.5,
|
||||
gameState.gameZoneHeight - gameState.puckHeight / 2,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
drawText(
|
||||
ctx,
|
||||
comboText,
|
||||
"#FFF",
|
||||
gameState.puckHeight,
|
||||
gameState.puckPosition,
|
||||
gameState.gameZoneHeight - gameState.puckHeight / 2,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Borders
|
||||
const hasCombo = gameState.combo > baseCombo(gameState);
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
if (gameState.offsetXRoundedDown) {
|
||||
// draw outside of gaming area to avoid capturing borders in recordings
|
||||
ctx.fillStyle =
|
||||
hasCombo && gameState.perks.left_is_lava ? "red" : gameState.puckColor;
|
||||
ctx.fillRect(gameState.offsetX - 1, 0, 1, height);
|
||||
ctx.fillStyle =
|
||||
hasCombo && gameState.perks.right_is_lava ? "red" : gameState.puckColor;
|
||||
ctx.fillRect(width - gameState.offsetX + 1, 0, 1, height);
|
||||
} else {
|
||||
ctx.fillStyle = "red";
|
||||
if (hasCombo && gameState.perks.left_is_lava) ctx.fillRect(0, 0, 1, height);
|
||||
if (hasCombo && gameState.perks.right_is_lava)
|
||||
ctx.fillRect(width - 1, 0, 1, height);
|
||||
}
|
||||
|
||||
if (gameState.perks.top_is_lava && gameState.combo > baseCombo(gameState)) {
|
||||
ctx.fillStyle = "red";
|
||||
ctx.fillRect(
|
||||
gameState.offsetXRoundedDown,
|
||||
0,
|
||||
gameState.gameZoneWidthRoundedUp,
|
||||
1,
|
||||
);
|
||||
}
|
||||
const redBottom =
|
||||
gameState.perks.compound_interest && gameState.combo > baseCombo(gameState);
|
||||
ctx.fillStyle = redBottom ? "red" : gameState.puckColor;
|
||||
if (isOptionOn("mobile-mode")) {
|
||||
ctx.fillRect(
|
||||
gameState.offsetXRoundedDown,
|
||||
gameState.gameZoneHeight,
|
||||
gameState.gameZoneWidthRoundedUp,
|
||||
1,
|
||||
);
|
||||
if (!gameState.running) {
|
||||
drawText(
|
||||
ctx,
|
||||
t('play.mobile_press_to_play'),
|
||||
gameState.puckColor,
|
||||
gameState.puckHeight,
|
||||
gameState.canvasWidth / 2,
|
||||
gameState.gameZoneHeight +
|
||||
(gameState.canvasHeight - gameState.gameZoneHeight) / 2,
|
||||
);
|
||||
}
|
||||
} else if (redBottom) {
|
||||
ctx.fillRect(
|
||||
gameState.offsetXRoundedDown,
|
||||
gameState.gameZoneHeight - 1,
|
||||
gameState.gameZoneWidthRoundedUp,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
if (shaked) {
|
||||
ctx.resetTransform();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let cachedBricksRender = document.createElement("canvas");
|
||||
let cachedBricksRenderKey = "";
|
||||
|
||||
export function renderAllBricks() {
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
const redBorderOnBricksWithWrongColor =
|
||||
gameState.combo > baseCombo(gameState) && gameState.perks.picky_eater && !isOptionOn('basic');
|
||||
|
||||
const newKey =
|
||||
gameState.gameZoneWidth +
|
||||
"_" +
|
||||
gameState.bricks.join("_") +
|
||||
bombSVG.complete +
|
||||
"_" +
|
||||
redBorderOnBricksWithWrongColor +
|
||||
"_" +
|
||||
gameState.ballsColor +
|
||||
"_" +
|
||||
gameState.perks.pierce_color;
|
||||
if (newKey !== cachedBricksRenderKey) {
|
||||
cachedBricksRenderKey = newKey;
|
||||
|
||||
cachedBricksRender.width = gameState.gameZoneWidth;
|
||||
cachedBricksRender.height = gameState.gameZoneWidth + 1;
|
||||
const canctx = cachedBricksRender.getContext(
|
||||
"2d",
|
||||
) as CanvasRenderingContext2D;
|
||||
canctx.clearRect(0, 0, gameState.gameZoneWidth, gameState.gameZoneWidth);
|
||||
canctx.resetTransform();
|
||||
canctx.translate(-gameState.offsetX, 0);
|
||||
// Bricks
|
||||
gameState.bricks.forEach((color, index) => {
|
||||
const x = brickCenterX(gameState, index),
|
||||
y = brickCenterY(gameState, index);
|
||||
|
||||
if (!color) return;
|
||||
|
||||
const borderColor =
|
||||
(gameState.ballsColor !== color &&
|
||||
color !== "black" &&
|
||||
redBorderOnBricksWithWrongColor &&
|
||||
"red") ||
|
||||
color;
|
||||
|
||||
drawBrick(canctx, color, borderColor, x, y);
|
||||
if (color === "black") {
|
||||
canctx.globalCompositeOperation = "source-over";
|
||||
drawIMG(canctx, bombSVG, gameState.brickWidth, x, y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.drawImage(cachedBricksRender, gameState.offsetX, 0);
|
||||
}
|
||||
|
||||
let cachedGraphics: { [k: string]: HTMLCanvasElement } = {};
|
||||
|
||||
export function drawPuck(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
color: colorString,
|
||||
puckWidth: number,
|
||||
puckHeight: number,
|
||||
yOffset = 0,
|
||||
) {
|
||||
const key = "puck" + color + "_" + puckWidth + "_" + puckHeight;
|
||||
|
||||
if (!cachedGraphics[key]) {
|
||||
const can = document.createElement("canvas");
|
||||
can.width = puckWidth;
|
||||
can.height = puckHeight * 2;
|
||||
const canctx = can.getContext("2d") as CanvasRenderingContext2D;
|
||||
canctx.fillStyle = color;
|
||||
|
||||
canctx.beginPath();
|
||||
canctx.moveTo(0, puckHeight * 2);
|
||||
canctx.lineTo(0, puckHeight * 1.25);
|
||||
canctx.bezierCurveTo(
|
||||
0,
|
||||
puckHeight * 0.75,
|
||||
puckWidth,
|
||||
puckHeight * 0.75,
|
||||
puckWidth,
|
||||
puckHeight * 1.25,
|
||||
);
|
||||
canctx.lineTo(puckWidth, puckHeight * 2);
|
||||
canctx.fill();
|
||||
cachedGraphics[key] = can;
|
||||
}
|
||||
|
||||
ctx.drawImage(
|
||||
cachedGraphics[key],
|
||||
Math.round(gameState.puckPosition - puckWidth / 2),
|
||||
gameState.gameZoneHeight - puckHeight * 2 + yOffset,
|
||||
);
|
||||
}
|
||||
|
||||
export function drawBall(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
color: colorString,
|
||||
width: number,
|
||||
x: number,
|
||||
y: number,
|
||||
borderColor = "",
|
||||
) {
|
||||
const key = "ball" + color + "_" + width + "_" + borderColor;
|
||||
|
||||
const size = Math.round(width);
|
||||
if (!cachedGraphics[key]) {
|
||||
const can = document.createElement("canvas");
|
||||
can.width = size;
|
||||
can.height = size;
|
||||
|
||||
const canctx = can.getContext("2d") as CanvasRenderingContext2D;
|
||||
canctx.beginPath();
|
||||
canctx.arc(size / 2, size / 2, Math.round(size / 2) - 1, 0, 2 * Math.PI);
|
||||
canctx.fillStyle = color;
|
||||
canctx.fill();
|
||||
if (borderColor) {
|
||||
canctx.lineWidth = 2;
|
||||
canctx.strokeStyle = borderColor;
|
||||
canctx.stroke();
|
||||
}
|
||||
|
||||
cachedGraphics[key] = can;
|
||||
}
|
||||
ctx.drawImage(
|
||||
cachedGraphics[key],
|
||||
Math.round(x - size / 2),
|
||||
Math.round(y - size / 2),
|
||||
);
|
||||
}
|
||||
|
||||
const angles = 32;
|
||||
|
||||
export function drawCoin(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
color: colorString,
|
||||
size: number,
|
||||
x: number,
|
||||
y: number,
|
||||
borderColor: colorString,
|
||||
rawAngle: number,
|
||||
) {
|
||||
const angle =
|
||||
((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) %
|
||||
angles;
|
||||
const key =
|
||||
"coin with halo" +
|
||||
"_" +
|
||||
color +
|
||||
"_" +
|
||||
size +
|
||||
"_" +
|
||||
borderColor +
|
||||
"_" +
|
||||
(color === "gold" ? angle : "whatever");
|
||||
|
||||
if (!cachedGraphics[key]) {
|
||||
const can = document.createElement("canvas");
|
||||
can.width = size;
|
||||
can.height = size;
|
||||
|
||||
const canctx = can.getContext("2d") as CanvasRenderingContext2D;
|
||||
|
||||
// coin
|
||||
canctx.beginPath();
|
||||
canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI);
|
||||
canctx.fillStyle = color;
|
||||
canctx.fill();
|
||||
|
||||
if (color === "gold") {
|
||||
canctx.strokeStyle = borderColor;
|
||||
canctx.stroke();
|
||||
|
||||
canctx.beginPath();
|
||||
canctx.arc(size / 2, size / 2, (size / 2) * 0.6, 0, 2 * Math.PI);
|
||||
canctx.fillStyle = "rgba(255,255,255,0.5)";
|
||||
canctx.fill();
|
||||
|
||||
canctx.translate(size / 2, size / 2);
|
||||
canctx.rotate(angle / 16);
|
||||
canctx.translate(-size / 2, -size / 2);
|
||||
|
||||
canctx.globalCompositeOperation = "multiply";
|
||||
drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1);
|
||||
drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1);
|
||||
}
|
||||
cachedGraphics[key] = can;
|
||||
}
|
||||
ctx.drawImage(
|
||||
cachedGraphics[key],
|
||||
Math.round(x - size / 2),
|
||||
Math.round(y - size / 2),
|
||||
);
|
||||
}
|
||||
|
||||
export function drawFuzzyBall(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
color: colorString,
|
||||
width: number,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const key = "fuzzy-circle" + color + "_" + width;
|
||||
if (!color) debugger;
|
||||
const size = Math.round(width * 3);
|
||||
if (!cachedGraphics[key]) {
|
||||
const can = document.createElement("canvas");
|
||||
can.width = size;
|
||||
can.height = size;
|
||||
|
||||
const canctx = can.getContext("2d") as CanvasRenderingContext2D;
|
||||
const gradient = canctx.createRadialGradient(
|
||||
size / 2,
|
||||
size / 2,
|
||||
0,
|
||||
size / 2,
|
||||
size / 2,
|
||||
size / 2,
|
||||
);
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, "transparent");
|
||||
canctx.fillStyle = gradient;
|
||||
canctx.fillRect(0, 0, size, size);
|
||||
cachedGraphics[key] = can;
|
||||
}
|
||||
ctx.drawImage(
|
||||
cachedGraphics[key],
|
||||
Math.round(x - size / 2),
|
||||
Math.round(y - size / 2),
|
||||
);
|
||||
}
|
||||
|
||||
export function drawBrick(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
color: colorString,
|
||||
borderColor: colorString,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const tlx = Math.ceil(x - gameState.brickWidth / 2);
|
||||
const tly = Math.ceil(y - gameState.brickWidth / 2);
|
||||
const brx = Math.ceil(x + gameState.brickWidth / 2) - 1;
|
||||
const bry = Math.ceil(y + gameState.brickWidth / 2) - 1;
|
||||
|
||||
const width = brx - tlx,
|
||||
height = bry - tly;
|
||||
const key = "brick" + color + "_" + borderColor + "_" + width + "_" + height;
|
||||
|
||||
if (!cachedGraphics[key]) {
|
||||
const can = document.createElement("canvas");
|
||||
can.width = width;
|
||||
can.height = height;
|
||||
const bord = 2;
|
||||
const cornerRadius = 2;
|
||||
const canctx = can.getContext("2d") as CanvasRenderingContext2D;
|
||||
|
||||
canctx.fillStyle = color;
|
||||
canctx.strokeStyle = borderColor;
|
||||
canctx.lineJoin = "round";
|
||||
canctx.lineWidth = bord;
|
||||
roundRect(
|
||||
canctx,
|
||||
bord / 2,
|
||||
bord / 2,
|
||||
width - bord,
|
||||
height - bord,
|
||||
cornerRadius,
|
||||
);
|
||||
canctx.fill();
|
||||
canctx.stroke();
|
||||
|
||||
cachedGraphics[key] = can;
|
||||
}
|
||||
ctx.drawImage(cachedGraphics[key], tlx, tly, width, height);
|
||||
// It's not easy to have a 1px gap between bricks without antialiasing
|
||||
}
|
||||
|
||||
export function roundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
export function drawIMG(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
size: number,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const key = "svg" + img + "_" + size + "_" + img.complete;
|
||||
|
||||
if (!cachedGraphics[key]) {
|
||||
const can = document.createElement("canvas");
|
||||
can.width = size;
|
||||
can.height = size;
|
||||
|
||||
const canctx = can.getContext("2d") as CanvasRenderingContext2D;
|
||||
|
||||
const ratio = size / Math.max(img.width, img.height);
|
||||
const w = img.width * ratio;
|
||||
const h = img.height * ratio;
|
||||
canctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
|
||||
|
||||
cachedGraphics[key] = can;
|
||||
}
|
||||
ctx.drawImage(
|
||||
cachedGraphics[key],
|
||||
Math.round(x - size / 2),
|
||||
Math.round(y - size / 2),
|
||||
);
|
||||
}
|
||||
|
||||
export function drawText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
color: colorString,
|
||||
fontSize: number,
|
||||
x: number,
|
||||
y: number,
|
||||
left = false,
|
||||
) {
|
||||
const key = "text" + text + "_" + color + "_" + fontSize + "_" + left;
|
||||
|
||||
if (!cachedGraphics[key]) {
|
||||
const can = document.createElement("canvas");
|
||||
can.width = fontSize * text.length;
|
||||
can.height = fontSize;
|
||||
const canctx = can.getContext("2d") as CanvasRenderingContext2D;
|
||||
canctx.fillStyle = color;
|
||||
canctx.textAlign = left ? "left" : "center";
|
||||
canctx.textBaseline = "middle";
|
||||
canctx.font = fontSize + "px monospace";
|
||||
|
||||
canctx.fillText(text, left ? 0 : can.width / 2, can.height / 2, can.width);
|
||||
|
||||
cachedGraphics[key] = can;
|
||||
}
|
||||
ctx.drawImage(
|
||||
cachedGraphics[key],
|
||||
left ? x : Math.round(x - cachedGraphics[key].width / 2),
|
||||
Math.round(y - cachedGraphics[key].height / 2),
|
||||
);
|
||||
}
|
||||
|
||||
export const scoreDisplay = document.getElementById("score") as HTMLButtonElement;
|
||||
const menuLabel = document.getElementById("menuLabel") as HTMLButtonElement;
|
|
@ -1,61 +0,0 @@
|
|||
import { GameState } from "./types";
|
||||
import { getMajorityValue } from "./game_utils";
|
||||
|
||||
export function resetBalls(gameState: GameState) {
|
||||
const count = 1 + (gameState.perks?.multiball || 0);
|
||||
const perBall = gameState.puckWidth / (count + 1);
|
||||
gameState.balls = [];
|
||||
gameState.ballsColor = "#FFF";
|
||||
if (gameState.perks.picky_eater || gameState.perks.pierce_color) {
|
||||
gameState.ballsColor =
|
||||
getMajorityValue(gameState.bricks.filter((i) => i)) || "#FFF";
|
||||
}
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x =
|
||||
gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1);
|
||||
const vx = Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed;
|
||||
|
||||
gameState.balls.push({
|
||||
x,
|
||||
previousX: x,
|
||||
y: gameState.gameZoneHeight - 1.5 * gameState.ballSize,
|
||||
previousY: gameState.gameZoneHeight - 1.5 * gameState.ballSize,
|
||||
vx,
|
||||
previousVX: vx,
|
||||
vy: -gameState.baseSpeed,
|
||||
previousVY: -gameState.baseSpeed,
|
||||
|
||||
sx: 0,
|
||||
sy: 0,
|
||||
sparks: 0,
|
||||
piercedSinceBounce: 0,
|
||||
hitSinceBounce: 0,
|
||||
hitItem: [],
|
||||
bouncesList: [],
|
||||
sapperUses: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function putBallsAtPuck(gameState: GameState) {
|
||||
// This reset could be abused to cheat quite easily
|
||||
const count = gameState.balls.length;
|
||||
const perBall = gameState.puckWidth / (count + 1);
|
||||
gameState.balls.forEach((ball, i) => {
|
||||
const x =
|
||||
gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1);
|
||||
ball.x = x;
|
||||
ball.previousX = x;
|
||||
ball.y = gameState.gameZoneHeight - 1.5 * gameState.ballSize;
|
||||
ball.previousY = ball.y;
|
||||
ball.vx = Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed;
|
||||
ball.previousVX = ball.vx;
|
||||
ball.vy = -gameState.baseSpeed;
|
||||
ball.previousVY = ball.vy;
|
||||
ball.sx = 0;
|
||||
ball.sy = 0;
|
||||
ball.hitItem = [];
|
||||
ball.hitSinceBounce = 0;
|
||||
ball.piercedSinceBounce = 0;
|
||||
});
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
// Settings
|
||||
|
||||
import {GameState} from "./types";
|
||||
|
||||
let cachedSettings: { [key: string]: unknown } = {};
|
||||
|
||||
export function getSettingValue<T>(key: string, defaultValue: T) {
|
||||
if (typeof cachedSettings[key] == "undefined") {
|
||||
try {
|
||||
const ls = localStorage.getItem("breakout-settings-enable-" + key);
|
||||
const ls = localStorage.getItem( key);
|
||||
if (ls) cachedSettings[key] = JSON.parse(ls) as T;
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
|
@ -17,9 +19,19 @@ export function getSettingValue<T>(key: string, defaultValue: T) {
|
|||
export function setSettingValue<T>(key: string, value: T) {
|
||||
cachedSettings[key] = value
|
||||
try {
|
||||
localStorage.setItem("breakout-settings-enable-" + key, JSON.stringify(value));
|
||||
localStorage.setItem( key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalScore() {
|
||||
return getSettingValue('breakout_71_total_score', 0)
|
||||
|
||||
}
|
||||
|
||||
export function addToTotalScore(gameState: GameState, points: number) {
|
||||
if (gameState.isCreativeModeRun) return;
|
||||
setSettingValue('breakout_71_total_score', getTotalScore() + points)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { gameState } from "./game";
|
||||
|
||||
|
||||
import {isOptionOn} from "./options";
|
||||
|
||||
export const sounds = {
|
||||
|
|
13
src/types.d.ts
vendored
13
src/types.d.ts
vendored
|
@ -1,4 +1,5 @@
|
|||
import { rawUpgrades } from "./rawUpgrades";
|
||||
import {rawUpgrades} from "./rawUpgrades";
|
||||
import {options} from "./options";
|
||||
|
||||
export type colorString = string;
|
||||
|
||||
|
@ -194,8 +195,7 @@ export type GameState = {
|
|||
// Will be set if the game is about to be paused. Game pause is delayed by a few milliseconds if you pause a few times in a run,
|
||||
// to avoid abuse of the "release to pause" feature on mobile.
|
||||
pauseTimeout: NodeJS.Timeout | null;
|
||||
// Whether the game should be rendered at the next tick, even if the game is paused
|
||||
needsRender: boolean;
|
||||
|
||||
// Current run score
|
||||
score: number;
|
||||
// levelTime of the last time the score increase, to render the score differently
|
||||
|
@ -241,3 +241,10 @@ export type RunParams = {
|
|||
levelToAvoid?: string;
|
||||
perks?: Partial<PerksMap>;
|
||||
};
|
||||
export type OptionDef = {
|
||||
default: boolean;
|
||||
name: string;
|
||||
help: string;
|
||||
disabled: () => boolean;
|
||||
};
|
||||
export type OptionId = keyof typeof options;
|
Loading…
Add table
Add a link
Reference in a new issue