2025-04-16 15:30:42 +02:00
|
|
|
import {
|
|
|
|
Ball,
|
|
|
|
Coin,
|
|
|
|
GameState,
|
|
|
|
Level,
|
|
|
|
PerkId,
|
|
|
|
PerksMap,
|
|
|
|
RunHistoryItem,
|
|
|
|
UpgradeLike,
|
|
|
|
} from "./types";
|
|
|
|
import { icons, upgrades } from "./loadGameData";
|
|
|
|
import { t } from "./i18n/i18n";
|
|
|
|
import { clamp } from "./pure_functions";
|
|
|
|
import { rawUpgrades } from "./upgrades";
|
|
|
|
import { hashCode } from "./getLevelBackground";
|
|
|
|
import { getTotalScore } from "./settings";
|
|
|
|
import { isOptionOn } from "./options";
|
2025-03-14 11:59:49 +01:00
|
|
|
|
2025-04-01 13:39:09 +02:00
|
|
|
export function describeLevel(level: Level) {
|
|
|
|
let bricks = 0,
|
|
|
|
colors = new Set(),
|
|
|
|
bombs = 0;
|
|
|
|
level.bricks.forEach((color) => {
|
|
|
|
if (!color) return;
|
|
|
|
if (color === "black") {
|
|
|
|
bombs++;
|
2025-04-01 13:35:33 +02:00
|
|
|
return;
|
2025-04-01 13:39:09 +02:00
|
|
|
} else {
|
|
|
|
colors.add(color);
|
|
|
|
bricks++;
|
2025-04-01 13:35:33 +02:00
|
|
|
}
|
2025-04-01 13:39:09 +02:00
|
|
|
});
|
|
|
|
return t("unlocks.level_description", {
|
|
|
|
size: level.size,
|
2025-04-01 13:35:33 +02:00
|
|
|
bricks,
|
2025-04-01 13:39:09 +02:00
|
|
|
colors: colors.size,
|
|
|
|
bombs,
|
|
|
|
});
|
2025-04-01 13:35:33 +02:00
|
|
|
}
|
|
|
|
|
2025-03-14 11:59:49 +01:00
|
|
|
export function getMajorityValue(arr: string[]): string {
|
2025-03-15 10:34:01 +01:00
|
|
|
const count: { [k: string]: number } = {};
|
|
|
|
arr.forEach((v) => (count[v] = (count[v] || 0) + 1));
|
|
|
|
// Object.values inline polyfill
|
|
|
|
const max = Math.max(...Object.keys(count).map((k) => count[k]));
|
|
|
|
return sample(Object.keys(count).filter((k) => count[k] == max));
|
2025-03-14 11:59:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function sample<T>(arr: T[]): T {
|
2025-03-15 10:34:01 +01:00
|
|
|
return arr[Math.floor(arr.length * Math.random())];
|
2025-03-14 11:59:49 +01:00
|
|
|
}
|
|
|
|
|
2025-03-26 14:04:54 +01:00
|
|
|
export function sumOfValues(obj: { [key: string]: number } | undefined | null) {
|
2025-03-15 10:34:01 +01:00
|
|
|
if (!obj) return 0;
|
|
|
|
return Object.values(obj)?.reduce((a, b) => a + b, 0) || 0;
|
2025-03-14 15:49:04 +01:00
|
|
|
}
|
|
|
|
|
2025-03-15 10:34:01 +01:00
|
|
|
export const makeEmptyPerksMap = (upgrades: { id: PerkId }[]) => {
|
|
|
|
const p = {} as any;
|
|
|
|
upgrades.forEach((u) => (p[u.id] = 0));
|
|
|
|
return p as PerksMap;
|
|
|
|
};
|
2025-03-16 14:29:14 +01:00
|
|
|
|
|
|
|
export function brickCenterX(gameState: GameState, index: number) {
|
|
|
|
return (
|
2025-03-16 17:45:29 +01:00
|
|
|
gameState.offsetX +
|
|
|
|
((index % gameState.gridSize) + 0.5) * gameState.brickWidth
|
2025-03-16 14:29:14 +01:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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 (
|
2025-03-16 17:45:29 +01:00
|
|
|
row < 0 ||
|
|
|
|
col < 0 ||
|
|
|
|
row >= gameState.gridSize ||
|
|
|
|
col >= gameState.gridSize
|
2025-03-16 14:29:14 +01:00
|
|
|
)
|
|
|
|
return -1;
|
|
|
|
return row * gameState.gridSize + col;
|
|
|
|
}
|
|
|
|
|
2025-04-10 21:40:45 +02:00
|
|
|
export function getClosestBall(
|
|
|
|
gameState: GameState,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
): Ball | null {
|
|
|
|
let closestBall: Ball | null = null;
|
|
|
|
let dist = 0;
|
|
|
|
gameState.balls.forEach((ball) => {
|
|
|
|
const d2 = (ball.x - x) * (ball.x - x) + (ball.y - y) * (ball.y - y);
|
|
|
|
if (d2 < dist || !closestBall) {
|
|
|
|
closestBall = ball;
|
|
|
|
dist = d2;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
return closestBall;
|
|
|
|
}
|
2025-03-16 14:29:14 +01:00
|
|
|
export function getPossibleUpgrades(gameState: GameState) {
|
|
|
|
return upgrades
|
2025-04-08 14:03:38 +02:00
|
|
|
.filter((u) => getTotalScore() >= u.threshold)
|
2025-03-16 17:45:29 +01:00
|
|
|
.filter((u) => !u?.requires || gameState.perks[u?.requires]);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function max_levels(gameState: GameState) {
|
2025-04-06 15:38:30 +02:00
|
|
|
if (gameState.creative) return 1;
|
|
|
|
return 7 + gameState.perks.extra_levels;
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function pickedUpgradesHTMl(gameState: GameState) {
|
2025-03-30 21:07:58 +02:00
|
|
|
const upgradesList = getPossibleUpgrades(gameState)
|
2025-04-06 18:21:53 +02:00
|
|
|
.filter((u) => gameState.perks[u.id])
|
2025-03-30 21:07:58 +02:00
|
|
|
.map((u) => {
|
2025-04-06 18:21:53 +02:00
|
|
|
const newMax = Math.max(0, u.max + gameState.perks.limitless);
|
2025-03-30 21:07:58 +02:00
|
|
|
|
2025-04-01 18:33:58 +02:00
|
|
|
let bars = [];
|
2025-03-30 21:07:58 +02:00
|
|
|
for (let i = 0; i < Math.max(u.max, newMax, gameState.perks[u.id]); i++) {
|
|
|
|
if (i < gameState.perks[u.id]) {
|
2025-04-01 18:33:58 +02:00
|
|
|
bars.push('<span class="used"></span>');
|
2025-03-30 21:07:58 +02:00
|
|
|
} else if (i < newMax) {
|
2025-04-01 18:33:58 +02:00
|
|
|
bars.push('<span class="free"></span>');
|
2025-03-30 21:07:58 +02:00
|
|
|
} else {
|
2025-04-01 18:33:58 +02:00
|
|
|
bars.push('<span class="banned"></span>');
|
2025-03-30 21:07:58 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-04-01 13:35:33 +02:00
|
|
|
const state = (gameState.perks[u.id] && 1) || (!newMax && 2) || 3;
|
2025-03-30 21:07:58 +02:00
|
|
|
return {
|
|
|
|
state,
|
|
|
|
html: `
|
2025-04-01 13:35:33 +02:00
|
|
|
<div class="upgrade ${["??", "used", "banned", "free"][state]}">
|
2025-03-30 21:07:58 +02:00
|
|
|
${u.icon}
|
|
|
|
<p>
|
|
|
|
<strong>${u.name}</strong>
|
|
|
|
${u.help(Math.max(1, gameState.perks[u.id]))}
|
|
|
|
</p>
|
2025-04-01 18:33:58 +02:00
|
|
|
${bars.reverse().join("")}
|
2025-03-30 21:07:58 +02:00
|
|
|
</div>
|
|
|
|
`,
|
|
|
|
};
|
|
|
|
})
|
|
|
|
.sort((a, b) => a.state - b.state)
|
|
|
|
.map((a) => a.html);
|
|
|
|
|
|
|
|
return ` <p>${t("score_panel.upgrades_picked")}</p>` + upgradesList.join("");
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-28 10:21:14 +01:00
|
|
|
|
2025-04-04 09:45:35 +02:00
|
|
|
export function levelsListHTMl(gameState: GameState, level: number) {
|
2025-03-23 22:19:28 +01:00
|
|
|
if (!gameState.perks.clairvoyant) return "";
|
2025-04-06 10:13:10 +02:00
|
|
|
if (gameState.creative) return "";
|
2025-03-23 19:11:01 +01:00
|
|
|
let list = "";
|
2025-03-23 22:19:28 +01:00
|
|
|
for (let i = 0; i < max_levels(gameState); i++) {
|
2025-04-04 09:45:35 +02:00
|
|
|
list += `<span style="opacity: ${i >= level ? 1 : 0.2}" title="${gameState.runLevels[i].name}">${icons[gameState.runLevels[i].name]}</span>`;
|
2025-03-23 19:11:01 +01:00
|
|
|
}
|
2025-03-23 22:19:28 +01:00
|
|
|
return `<p>${t("score_panel.upcoming_levels")}</p><p>${list}</p>`;
|
2025-03-23 19:11:01 +01:00
|
|
|
}
|
2025-03-16 14:29:14 +01:00
|
|
|
|
|
|
|
export function currentLevelInfo(gameState: GameState) {
|
2025-03-27 10:52:31 +01:00
|
|
|
return gameState.level;
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
2025-04-01 21:43:36 +02:00
|
|
|
export function isPickyEatingPossible(gameState: GameState) {
|
|
|
|
return gameState.bricks.indexOf(gameState.ballsColor) !== -1;
|
2025-04-01 21:37:07 +02:00
|
|
|
}
|
|
|
|
|
2025-04-01 21:43:36 +02:00
|
|
|
export function reachRedRowIndex(gameState: GameState) {
|
|
|
|
if (!gameState.perks.reach) return -1;
|
|
|
|
const { size } = gameState.level;
|
|
|
|
let minY = -1,
|
|
|
|
maxY = -1,
|
|
|
|
maxYCount = -1;
|
|
|
|
for (let y = 0; y < size; y++)
|
|
|
|
for (let x = 0; x < size; x++)
|
|
|
|
if (gameState.bricks[x + y * size]) {
|
|
|
|
if (minY == -1) minY = y;
|
|
|
|
if (maxY < y) {
|
|
|
|
maxY = y;
|
|
|
|
maxYCount = 0;
|
|
|
|
}
|
|
|
|
if (maxY == y) maxYCount++;
|
2025-04-01 21:37:07 +02:00
|
|
|
}
|
|
|
|
|
2025-04-01 21:43:36 +02:00
|
|
|
if (maxY < 1) return -1;
|
|
|
|
if (maxY == minY) return -1;
|
|
|
|
if (maxYCount === size) return -1;
|
|
|
|
return maxY;
|
2025-04-01 21:37:07 +02:00
|
|
|
}
|
|
|
|
|
2025-04-04 09:45:35 +02:00
|
|
|
export function telekinesisEffectRate(gameState: GameState, ball: Ball) {
|
|
|
|
return (
|
|
|
|
(gameState.perks.telekinesis &&
|
|
|
|
ball.vy < 0 &&
|
|
|
|
clamp((ball.y / gameState.gameZoneHeight) * 1.1 + 0.1, 0, 1)) ||
|
|
|
|
0
|
|
|
|
);
|
2025-03-19 20:14:55 +01:00
|
|
|
}
|
2025-04-06 15:38:30 +02:00
|
|
|
|
2025-04-04 09:45:35 +02:00
|
|
|
export function yoyoEffectRate(gameState: GameState, ball: Ball) {
|
|
|
|
return (
|
|
|
|
(gameState.perks.yoyo &&
|
|
|
|
ball.vy > 0 &&
|
|
|
|
clamp(1 - (ball.y / gameState.gameZoneHeight) * 1.1 + 0.1, 0, 1)) ||
|
|
|
|
0
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function findLast<T>(
|
2025-03-16 17:45:29 +01:00
|
|
|
arr: T[],
|
|
|
|
predicate: (item: T, index: number, array: T[]) => boolean,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
|
|
|
let i = arr.length;
|
|
|
|
while (--i)
|
|
|
|
if (predicate(arr[i], i, arr)) {
|
|
|
|
return arr[i];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export function distance2(
|
2025-03-16 17:45:29 +01:00
|
|
|
a: { x: number; y: number },
|
|
|
|
b: { x: number; y: number },
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
|
|
|
return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2);
|
|
|
|
}
|
|
|
|
|
|
|
|
export function distanceBetween(
|
2025-03-16 17:45:29 +01:00
|
|
|
a: { x: number; y: number },
|
|
|
|
b: { x: number; y: number },
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
|
|
|
return Math.sqrt(distance2(a, b));
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
2025-03-17 19:47:16 +01:00
|
|
|
|
|
|
|
export function defaultSounds() {
|
2025-03-18 14:16:12 +01:00
|
|
|
return {
|
|
|
|
aboutToPlaySound: {
|
|
|
|
wallBeep: { vol: 0, x: 0 },
|
|
|
|
comboIncreaseMaybe: { vol: 0, x: 0 },
|
|
|
|
comboDecrease: { vol: 0, x: 0 },
|
|
|
|
coinBounce: { vol: 0, x: 0 },
|
|
|
|
explode: { vol: 0, x: 0 },
|
|
|
|
lifeLost: { vol: 0, x: 0 },
|
|
|
|
coinCatch: { vol: 0, x: 0 },
|
2025-04-15 16:47:04 +02:00
|
|
|
plouf: { vol: 0, x: 0 },
|
2025-03-18 14:16:12 +01:00
|
|
|
colorChange: { vol: 0, x: 0 },
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
2025-03-19 20:14:55 +01:00
|
|
|
|
|
|
|
export function shouldPierceByColor(
|
2025-03-19 21:58:50 +01:00
|
|
|
gameState: GameState,
|
|
|
|
vhit: number | undefined,
|
|
|
|
hhit: number | undefined,
|
|
|
|
chit: number | undefined,
|
2025-03-19 20:14:55 +01:00
|
|
|
) {
|
2025-03-19 21:58:50 +01:00
|
|
|
if (!gameState.perks.pierce_color) return false;
|
|
|
|
if (
|
|
|
|
typeof vhit !== "undefined" &&
|
|
|
|
gameState.bricks[vhit] !== gameState.ballsColor
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
typeof hhit !== "undefined" &&
|
|
|
|
gameState.bricks[hhit] !== gameState.ballsColor
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
typeof chit !== "undefined" &&
|
|
|
|
gameState.bricks[chit] !== gameState.ballsColor
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
2025-03-20 21:02:51 +01:00
|
|
|
|
2025-04-02 19:36:03 +02:00
|
|
|
export function isMovingWhilePassiveIncome(gameState: GameState) {
|
|
|
|
return !!(
|
|
|
|
gameState.lastPuckMove &&
|
|
|
|
gameState.perks.passive_income &&
|
|
|
|
gameState.lastPuckMove >
|
|
|
|
gameState.levelTime - 250 * gameState.perks.passive_income
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
export function getHighScore() {
|
|
|
|
try {
|
|
|
|
return parseInt(localStorage.getItem("breakout-3-hs-short") || "0");
|
2025-04-01 13:39:09 +02:00
|
|
|
} catch (e) {}
|
2025-04-06 15:38:30 +02:00
|
|
|
return 0;
|
2025-04-06 10:13:10 +02:00
|
|
|
}
|
2025-04-01 13:35:33 +02:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
export function highScoreText() {
|
|
|
|
if (getHighScore()) {
|
|
|
|
return t("main_menu.high_score", { score: getHighScore() });
|
|
|
|
}
|
2025-04-01 13:39:09 +02:00
|
|
|
return "";
|
2025-04-01 13:35:33 +02:00
|
|
|
}
|
2025-04-06 11:57:52 +02:00
|
|
|
|
2025-04-08 10:36:30 +02:00
|
|
|
let excluded: Set<PerkId>;
|
2025-04-08 14:03:38 +02:00
|
|
|
function isExcluded(id: PerkId) {
|
|
|
|
if (!excluded) {
|
2025-04-08 10:36:30 +02:00
|
|
|
excluded = new Set([
|
2025-04-07 14:08:48 +02:00
|
|
|
"extra_levels",
|
|
|
|
"extra_life",
|
|
|
|
"one_more_choice",
|
|
|
|
"instant_upgrade",
|
|
|
|
"shunt",
|
|
|
|
"slow_down",
|
|
|
|
]);
|
|
|
|
// Avoid excluding a perk that's needed for the required one
|
|
|
|
rawUpgrades.forEach((u) => {
|
|
|
|
if (u.requires) excluded.add(u.requires);
|
|
|
|
});
|
2025-04-08 10:36:30 +02:00
|
|
|
}
|
2025-04-08 14:03:38 +02:00
|
|
|
return excluded.has(id);
|
2025-04-08 10:36:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
export function getLevelUnlockCondition(levelIndex: number) {
|
|
|
|
let required: UpgradeLike[] = [],
|
|
|
|
forbidden: UpgradeLike[] = [],
|
|
|
|
minScore = Math.max(-1000 + 100 * levelIndex, 0);
|
|
|
|
|
|
|
|
if (levelIndex > 20) {
|
2025-04-07 14:08:48 +02:00
|
|
|
const possibletargets = rawUpgrades
|
|
|
|
.slice(0, Math.floor(levelIndex / 2))
|
|
|
|
.map((u) => u)
|
2025-04-08 10:36:30 +02:00
|
|
|
.filter((u) => !isExcluded(u.id))
|
2025-04-07 14:08:48 +02:00
|
|
|
.sort(
|
|
|
|
(a, b) => hashCode(levelIndex + a.id) - hashCode(levelIndex + b.id),
|
|
|
|
);
|
|
|
|
|
2025-04-08 14:29:00 +02:00
|
|
|
const length = Math.min(3, Math.ceil(levelIndex / 30));
|
2025-04-07 14:08:48 +02:00
|
|
|
required = possibletargets.slice(0, length);
|
|
|
|
forbidden = possibletargets.slice(length, length + length);
|
2025-04-06 11:57:52 +02:00
|
|
|
}
|
2025-04-07 14:08:48 +02:00
|
|
|
return {
|
|
|
|
required,
|
|
|
|
forbidden,
|
|
|
|
minScore,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
export function getBestScoreMatching(
|
|
|
|
history: RunHistoryItem[],
|
|
|
|
required: UpgradeLike[] = [],
|
|
|
|
forbidden: UpgradeLike[] = [],
|
|
|
|
) {
|
|
|
|
return Math.max(
|
|
|
|
0,
|
|
|
|
...history
|
|
|
|
.filter(
|
|
|
|
(r) =>
|
|
|
|
!required.find((u) => !r?.perks?.[u.id]) &&
|
|
|
|
!forbidden.find((u) => r?.perks?.[u.id]),
|
|
|
|
)
|
|
|
|
.map((r) => r.score),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
export function reasonLevelIsLocked(
|
|
|
|
levelIndex: number,
|
|
|
|
history: RunHistoryItem[],
|
|
|
|
mentionBestScore: boolean,
|
|
|
|
): null | { reached: number; minScore: number; text: string } {
|
|
|
|
const { required, forbidden, minScore } = getLevelUnlockCondition(levelIndex);
|
|
|
|
|
|
|
|
const reached = getBestScoreMatching(history, required, forbidden);
|
|
|
|
let reachedText =
|
|
|
|
reached && mentionBestScore ? t("unlocks.reached", { reached }) : "";
|
|
|
|
if (reached >= minScore) {
|
|
|
|
return null;
|
|
|
|
} else if (!required.length && !forbidden.length) {
|
|
|
|
return {
|
|
|
|
reached,
|
|
|
|
minScore,
|
|
|
|
text: t("unlocks.minScore", { minScore }) + reachedText,
|
|
|
|
};
|
2025-04-06 15:38:30 +02:00
|
|
|
} else {
|
2025-04-07 14:08:48 +02:00
|
|
|
return {
|
|
|
|
reached,
|
2025-04-06 15:38:30 +02:00
|
|
|
minScore,
|
2025-04-07 14:08:48 +02:00
|
|
|
text:
|
|
|
|
t("unlocks.minScoreWithPerks", {
|
|
|
|
minScore,
|
|
|
|
required: required.map((u) => u.name).join(", "),
|
|
|
|
forbidden: forbidden.map((u) => u.name).join(", "),
|
|
|
|
}) + reachedText,
|
|
|
|
};
|
2025-04-06 11:57:52 +02:00
|
|
|
}
|
2025-04-06 15:38:30 +02:00
|
|
|
}
|
2025-04-10 14:49:28 +02:00
|
|
|
|
|
|
|
export function ballTransparency(ball: Ball, gameState: GameState) {
|
2025-04-10 21:40:45 +02:00
|
|
|
if (!gameState.perks.transparency) return 0;
|
2025-04-10 14:49:28 +02:00
|
|
|
return clamp(
|
2025-04-10 21:40:45 +02:00
|
|
|
gameState.perks.transparency *
|
|
|
|
(1 - (ball.y / gameState.gameZoneHeight) * 1.2),
|
2025-04-10 14:49:28 +02:00
|
|
|
0,
|
|
|
|
1,
|
|
|
|
);
|
|
|
|
}
|
2025-04-16 15:30:20 +02:00
|
|
|
|
|
|
|
export function getCoinRenderColor(gameState: GameState, coin: Coin) {
|
2025-04-16 15:30:42 +02:00
|
|
|
if (
|
|
|
|
gameState.perks.metamorphosis ||
|
|
|
|
isOptionOn("colorful_coins") ||
|
|
|
|
gameState.perks.hypnosis ||
|
|
|
|
gameState.perks.rainbow
|
|
|
|
)
|
|
|
|
return coin.color;
|
|
|
|
return "#ffd300";
|
|
|
|
}
|
2025-04-18 21:17:32 +02:00
|
|
|
|
|
|
|
export function getCornerOffset(gameState: GameState) {
|
|
|
|
return (
|
|
|
|
(gameState.levelTime
|
|
|
|
? gameState.perks.corner_shot * gameState.brickWidth
|
|
|
|
: 0) -
|
|
|
|
gameState.perks.unbounded * gameState.brickWidth
|
|
|
|
);
|
|
|
|
}
|