This commit is contained in:
Renan LE CARO 2025-02-17 00:49:03 +01:00
parent 0ff6ae1fdf
commit d3296c4f0f
2 changed files with 262 additions and 152 deletions

View file

@ -14,12 +14,12 @@ At the end of each level, you get to select an upgrade.
## TODO
- Fdroid
- pause/resume audio context
- perk : elastic between balls
- easily start a test game with specific upgrades or levels (with query string or through menu)
- show total score on end screen (score added to total)
- show stats on end screen compared to other runs
- handle back bouton in menu
- more levels : famous simple games, letters, fruits, animals
- perk : elastic between balls
- perk : wrap left / right
- perk : twice as many coins after a wall bounce, twice as little otherwise
- perk : fusion reactor (gather coins in one spot to triple their value)
@ -27,8 +27,8 @@ At the end of each level, you get to select an upgrade.
- perk : n/10 of the broken bricks respawn when the ball comes back
- perk : bricks take twice as many hits but drop 50% more coins
- perk : wind (puck positions adds force to coins and balls)
- perk : balls repulse each other
- perk : balls repulse coins
- missing triggers and explosive lighting strike around ball path
## maybe

View file

@ -9,7 +9,7 @@ const puckHeight = ballSize;
if (allLevels.find(l => l.focus)) {
allLevels = allLevels.filter(l => l.focus)
}
allLevels=allLevels.filter(l=>!l.draft)
allLevels = allLevels.filter(l => !l.draft)
let runLevels = []
@ -33,7 +33,7 @@ function baseCombo() {
return 1 + perks.base_combo * 3;
}
function resetCombo(x, y ) {
function resetCombo(x, y) {
const prev = combo;
combo = baseCombo();
if (!levelTime) {
@ -88,18 +88,20 @@ function decreaseCombo(by, x, y) {
let gridSize = 12;
let running = false, puck = 400;
function play(){
if(running) return
function play() {
if (running) return
running = true
if(audioContext){
if (audioContext) {
audioContext.resume()
}
}
function pause(){
if(!running) return
function pause() {
if (!running) return
running = false
needsRender=true
if(audioContext){
needsRender = true
if (audioContext) {
audioContext.suspend()
}
}
@ -191,7 +193,7 @@ function addToScore(coin) {
coin.destroyed = true
score += coin.points;
addToTotalScore(coin.points)
if (score > highScore) {
if (score > highScore && !hadOverrides) {
highScore = score;
localStorage.setItem("breakout-3-hs", score);
}
@ -373,46 +375,49 @@ function reset_perks() {
perks[u.id] = 0;
}
const giftable = getPossibleUpgrades().filter(u => u.giftable)
if (!giftable.length) {
debugger
if (nextRunOverrides.perks) {
const first = Object.keys(nextRunOverrides.perks)[0]
Object.assign(perks, nextRunOverrides.perks)
nextRunOverrides.perks = null
return first
}
const giftable = getPossibleUpgrades().filter(u => u.giftable && u.max > 0)
const randomGift = isSettingOn('easy') ? 'slow_down' : giftable[Math.floor(Math.random() * giftable.length)].id;
perks[randomGift] = 1;
// TODO
// perks.puck_repulse_ball = 3
// perks.multiball = 1
// perks.ball_repulse_ball = 1
// perks.puck_repulse_ball=3
// perks.ball_repulse_ball=3
// perks.ball_attract_ball=3
// perks.multiball=3
return randomGift
}
const upgrades = [{
const upgrades = [
{
minimumTotalScore: 3000,
id: 'multiball',
giftableAfterTotalScore: 20000,
giftable: true,
name: "+1 ball",
max: 3,
help: `Start each level with one more balls.`,
}, {
minimumTotalScore: 5000,
id: 'pierce',
giftableAfterTotalScore: 15000,
giftable: true,
name: "Ball pierces bricks",
max: 3,
help: `Pierce through 3 blocks after bouncing on the puck.`,
}, {
minimumTotalScore: 500,
id: 'telekinesis',
giftableAfterTotalScore: 900,
giftable: true,
name: "Puck controls ball",
max: 2,
help: `Control the ball's trajectory with the puck.`,
}, {
minimumTotalScore: 0,
extra_levels_minimum_total_score: 250,
id: 'extra_life',
name: "+1 life",
max: 3,
@ -420,7 +425,7 @@ const upgrades = [{
}, {
minimumTotalScore: 20000,
id: 'sapper',
giftableAfterTotalScore: 32000,
giftable: true,
name: "Bricks become bombs",
max: 1,
help: `Broken blocks are replaced by bombs.`,
@ -460,7 +465,7 @@ const upgrades = [{
{
minimumTotalScore: 6000,
id: 'picky_eater',
giftableAfterTotalScore: 9000,
giftable: true,
name: "Single color streak",
color_blind_exclude: true,
max: 1,
@ -479,7 +484,7 @@ const upgrades = [{
{
minimumTotalScore: 0,
id: 'streak_shots',
giftableAfterTotalScore: 1500,
giftable: true,
name: "Single puck hit streak",
max: 1,
help: `Break many bricks at once for more coins.`,
@ -488,7 +493,7 @@ const upgrades = [{
{
minimumTotalScore: 10000,
id: 'hot_start',
giftableAfterTotalScore: 24000,
giftable: true,
name: "Hot start",
max: 3,
help: `Clear the level quickly for more coins.`,
@ -497,14 +502,14 @@ const upgrades = [{
{
minimumTotalScore: 200,
id: 'sides_are_lava',
giftableAfterTotalScore: 500,
giftable: true,
name: "Shoot straight",
max: 1,
help: `Avoid the sides for more coins.`,
}, {
minimumTotalScore: 600,
id: 'top_is_lava',
giftableAfterTotalScore: 1200,
giftable: true,
name: "Sky is the limit",
max: 1,
help: `Avoid the top for more coins.`,
@ -513,13 +518,12 @@ const upgrades = [{
{
minimumTotalScore: 8000,
id: 'catch_all_coins',
giftableAfterTotalScore: 16000,
giftable: true,
name: "Compound interest",
max: 3,
help: `Catch all coins with your puck for even more coins.`,
}, {
minimumTotalScore: 0,
extra_levels_minimum_total_score: 6250,
id: 'viscosity',
name: "Slower coins fall",
max: 3,
@ -528,9 +532,8 @@ const upgrades = [{
{
minimumTotalScore: 0,
extra_levels_minimum_total_score: 750,
id: 'base_combo',
giftableAfterTotalScore: 0,
giftable: true,
name: "+3 base combo",
max: 3,
help: `Your combo starts 3 points higher.`,
@ -538,7 +541,6 @@ const upgrades = [{
{
minimumTotalScore: 0,
extra_levels_minimum_total_score: 25,
id: 'slow_down',
name: "Slower ball",
max: 2,
@ -559,7 +561,6 @@ const upgrades = [{
minimumTotalScore: 3600, id: 'smaller_puck', name: "Smaller puck", max: 2, help: `Gives you more control.`,
}, {
minimumTotalScore: 0,
extra_levels_minimum_total_score: 0,
id: 'bigger_puck',
name: "Bigger puck",
max: 2,
@ -570,6 +571,12 @@ const upgrades = [{
name: "Balls repulse balls",
max: 3,
help: `Only has an effect when 2+ balls.`,
}, {
minimumTotalScore: 2000,
id: 'ball_attract_ball',
name: "Balls attract balls",
max: 3,
help: `Only has an effect when 2+ balls.`,
}, {
minimumTotalScore: 4000,
id: 'puck_repulse_ball',
@ -577,32 +584,17 @@ const upgrades = [{
max: 3,
help: `Prevents the puck from touching the balls.`,
}
]
]
function computeUpgradeCurrentMaxLevel(u, ts) {
let max = 0
const setMax = (v) => max = Math.max(max, v)
if (u.max && ts >= u.minimumTotalScore) {
setMax(1)
}
if (u.max > 1) {
if (u.minimumTotalScore) {
setMax(Math.min(u.max, Math.floor(ts / u.minimumTotalScore)))
} else if (u.extra_levels_minimum_total_score) {
setMax(Math.min(u.max, Math.floor(ts / u.extra_levels_minimum_total_score) + 1))
}
}
return max
}
function getPossibleUpgrades() {
const ts = getTotalScore()
return upgrades
.filter(u => !(isSettingOn('color_blind') && u.color_blind_exclude))
.map(u => ({
...u, max: computeUpgradeCurrentMaxLevel(u, ts), giftable: ts >= (u.giftableAfterTotalScore ?? Infinity)
})).filter(u => u.max > 0)
...u, max: ts > u.minimumTotalScore ? u.max:0, originalMax: u.max
}))
}
function levelTotalScoreCondition(l, li) {
@ -610,13 +602,16 @@ function levelTotalScoreCondition(l, li) {
}
function shuffleLevels(nameToAvoid = null) {
const ts = getTotalScore()
const ts = getTotalScore();
runLevels = allLevels
.filter(l => nextRunOverrides.level ? l.name === nextRunOverrides.level : true)
.filter((l, li) => ts >= levelTotalScoreCondition(l, li))
.filter(l => l.name !== nameToAvoid || allLevels.length === 1)
.sort(() => Math.random() - 0.5)
.slice(0, 7 + 3)
.sort((a, b) => a.bricks.filter(i => i).length - b.bricks.filter(i => i).length)
.sort((a, b) => a.bricks.filter(i => i).length - b.bricks.filter(i => i).length);
nextRunOverrides.level = null
}
function getUpgraderUnlockPoints() {
@ -628,32 +623,18 @@ function getUpgraderUnlockPoints() {
if (u.minimumTotalScore) {
list.push({
threshold: u.minimumTotalScore,
title: 'Unlock: ' + u.name,
help: 'This new perks will be added to the choices offered to you.'
title: u.name + ' (Perk)',
help: u.help,
})
}
if (u.max > 1) {
for (var l = 1; l < u.max; l++) list.push({
threshold: l * (u.minimumTotalScore || u.extra_levels_minimum_total_score || 0),
title: 'Upgrade: ' + u.name,
help: 'You will be able to take this perk ' + (l + 1) + ' times for greater effect.'
})
}
if (u.giftableAfterTotalScore) {
list.push({
threshold: u.giftableAfterTotalScore,
title: 'Start: ' + u.name,
help: u.name + ' will be added to the list of possible starting perks.'
})
}
})
allLevels.forEach((l, li) => {
list.push({
threshold: levelTotalScoreCondition(l, li),
title: 'Level: ' + l.name,
help: l.name + ' will be added to the list of possible levels.'
title: l.name + ' (Level)',
// help: 'Adds level "'+l.name + '" to the list of possible levels.',
})
})
@ -663,7 +644,6 @@ function getUpgraderUnlockPoints() {
function pickRandomUpgrades(count) {
let list = getPossibleUpgrades()
.sort(() => Math.random() - 0.5)
.filter(u => perks[u.id] < u.max)
@ -688,15 +668,21 @@ function pickRandomUpgrades(count) {
return list;
}
let nextRunOverrides = {level: null, perks: null}
let hadOverrides = false
function restart() {
console.log("restart")
hadOverrides = !!(nextRunOverrides.level || nextRunOverrides.perks)
// When restarting, we want to avoid restarting with the same level we're on, so we exclude from the next
// run's level list
shuffleLevels(levelTime || score ? currentLevelInfo().name : null);
resetRunStatistics()
score = 0;
scoreStory = [];
if (hadOverrides) {
scoreStory.push(`This is a test run, started from the unlocks menu. It stops after one level and is not recorded in the stats. `)
}
const randomGift = reset_perks();
incrementRunStatistics('starting_upgrade.' + randomGift, 1)
@ -734,9 +720,9 @@ function setMousePos(x) {
canvas.addEventListener("mouseup", (e) => {
if (e.button !== 0) return;
if(running) {
if (running) {
pause()
}else {
} else {
play()
}
});
@ -922,6 +908,7 @@ function tick() {
let playedCoinBounce = false;
const coinRadius = Math.round(coinSize / 2);
coins.forEach((coin) => {
if (coin.destroyed) return;
if (perks.coin_magnet) {
@ -1008,33 +995,39 @@ function ballTick(ball, delta) {
ball.vx += ((puck - ball.x) / 1000) * delta * perks.telekinesis;
}
const speedLimitDampener =1+ perks.telekinesis+perks.ball_repulse_ball +perks.puck_repulse_ball
const speedLimitDampener = 1 + perks.telekinesis + perks.ball_repulse_ball + perks.puck_repulse_ball + perks.ball_attract_ball
if (ball.vx * ball.vx + ball.vy * ball.vy < baseSpeed * baseSpeed * 2) {
ball.vx *= (1 + .02/speedLimitDampener);
ball.vy *= (1 + .02/speedLimitDampener);
ball.vx *= (1 + .02 / speedLimitDampener);
ball.vy *= (1 + .02 / speedLimitDampener);
} else {
ball.vx *= (1 - .02/speedLimitDampener);;
ball.vx *= (1 - .02 / speedLimitDampener);
;
if (Math.abs(ball.vy) > 0.5 * baseSpeed) {
ball.vy *= (1 - .02/speedLimitDampener);;
ball.vy *= (1 - .02 / speedLimitDampener);
;
}
}
if(perks.ball_repulse_ball){
for(b2 of balls){
if (perks.ball_repulse_ball) {
for (b2 of balls) {
// avoid computing this twice, and repulsing itself
if(b2.x>=ball.x) continue
repulse(ball,b2,15* perks.ball_repulse_ball )
if (b2.x >= ball.x) continue
repulse(ball, b2, perks.ball_repulse_ball, true)
}
}
if(perks.puck_repulse_ball){
repulse(ball,{
x:puck,
y:gameZoneHeight,
vx:0,
vy:0,
color:currentLevelInfo().black_puck ? '#000' : '#FFF' ,
},15* perks.puck_repulse_ball )
if (perks.ball_attract_ball) {
for (b2 of balls) {
// avoid computing this twice, and repulsing itself
if (b2.x >= ball.x) continue
attract(ball, b2, 2 * perks.ball_attract_ball)
}
}
if (perks.puck_repulse_ball) {
repulse(ball, {
x: puck,
y: gameZoneHeight,
color: currentLevelInfo().black_puck ? '#000' : '#FFF',
}, perks.puck_repulse_ball, false)
}
@ -1080,14 +1073,14 @@ function ballTick(ball, delta) {
x: ball.previousx,
y: ball.previousy
})
for(si=0; si< ball.bouncesList.length-1;si++){
for (si = 0; si < ball.bouncesList.length - 1; si++) {
// segement
const start= ball.bouncesList[si]
const end= ball.bouncesList[si+1]
const distance= distanceBetween(start,end)
const start = ball.bouncesList[si]
const end = ball.bouncesList[si + 1]
const distance = distanceBetween(start, end)
const parts = distance/30
for(var i = 0; i <parts;i++ ){
const parts = distance / 30
for (var i = 0; i < parts; i++) {
flashes.push({
type: "particle",
duration: 200,
@ -1095,8 +1088,8 @@ function ballTick(ball, delta) {
time: levelTime,
size: coinSize / 2,
color: ball.color,
x: start.x + (i/(parts-1))*(end.x-start.x),
y: start.y + (i/(parts-1))*(end.y-start.y),
x: start.x + (i / (parts - 1)) * (end.x - start.x),
y: start.y + (i / (parts - 1)) * (end.y - start.y),
vx: (Math.random() - 0.5) * baseSpeed,
vy: (Math.random() - 0.5) * baseSpeed,
});
@ -1109,8 +1102,8 @@ function ballTick(ball, delta) {
ball.hitSinceBounce = 0;
ball.piercedSinceBounce = 0;
ball.bouncesList = [{
x: ball.previousx,
y: ball.previousy
x: ball.previousx,
y: ball.previousy
}]
}
@ -1173,6 +1166,7 @@ function resetRunStatistics() {
runStatistics = {
started: Date.now(),
ended: null,
hadOverrides,
width: window.innerWidth,
height: window.innerHeight,
easy: isSettingOn('easy'),
@ -1195,6 +1189,7 @@ function getTotalScore() {
}
function addToTotalScore(points) {
if (hadOverrides) return
try {
localStorage.setItem('breakout_71_total_score', JSON.stringify(getTotalScore() + points))
} catch (e) {
@ -1238,7 +1233,7 @@ function gameOver(title, intro) {
const list = getUpgraderUnlockPoints()
list.filter(u => u.threshold > startTs && u.threshold < endTs).forEach(u => {
unlocksInfo += `
<p class="progress" title=${JSON.stringify(u.help)}>
<p class="progress" title=${JSON.stringify(u.help || '')}>
<span>${u.title}</span>
<span class="progress_bar_part" style="${getDelay()}"></span>
</p>
@ -1253,10 +1248,11 @@ function gameOver(title, intro) {
const done = endTs - previousUnlockAt
intro += `Score ${nextUnlock.threshold - endTs} more points to reach the next unlock.`
const scaleX=(done / total).toFixed(2)
unlocksInfo += `
<p class="progress" title=${JSON.stringify(unlocksInfo.help)}>
<span>${nextUnlock.title}</span>
<span style="transform: scale(${(done / total).toFixed(2)},1);${getDelay()}" class="progress_bar_part"></span>
<span style="transform: scale(${scaleX},1);${getDelay()}" class="progress_bar_part"></span>
</p>
`
@ -1270,7 +1266,7 @@ function gameOver(title, intro) {
}
// Avoid the sad sound right as we restart a new games
combo=1
combo = 1
asyncAlert({
allowClose: true, title, text: `
<p>${intro}</p>
@ -1375,6 +1371,7 @@ function explodeBrick(index, ball, isExplosion) {
}
function max_levels() {
if (hadOverrides) return 1
return 7 + perks.extra_levels;
}
@ -1432,9 +1429,13 @@ function render() {
const {x, y, time, color, size, type, duration} = flash;
const elapsed = levelTime - time;
ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
if (type === "ball" || type === "particle") {
if (type === "ball") {
drawFuzzyBall(ctx, color, size, x, y);
}
if (type === "particle") {
drawFuzzyBall(ctx, color, size * 3, x, y);
}
});
ctx.globalAlpha = 0.9;
@ -1565,10 +1566,10 @@ function render() {
ctx.globalCompositeOperation = "source-over";
drawPuck(ctx, puckColor, puckWidth, puckHeight)
if (combo > 1) {
ctx.globalCompositeOperation = "destination-out";
drawText(ctx, "x " + combo, "white", puckHeight, {
ctx.globalCompositeOperation = "source-over";
drawText(ctx, "x " + combo, !level.black_puck ? '#000' : '#FFF', puckHeight, {
x: puck, y: gameZoneHeight - puckHeight / 2,
});
}
@ -2136,12 +2137,11 @@ function toggleSetting(key) {
scoreDisplay.addEventListener("click", async (e) => {
e.preventDefault();
running=false
running = false
const cb = await asyncAlert({
title: `You scored ${score} points so far`, text: `
<p>You are playing level ${currentLevel + 1} out of ${max_levels()}. </p>
${scoreStory.map((t) => "<p>" + t + "</p>").join("")}
<p>You high score is ${highScore}.</p>
`, allowClose: true, actions: [{
text: "New run", help: "Start a brand new run.", value: () => {
restart();
@ -2200,6 +2200,57 @@ async function openSettingsPanel() {
const cb = await asyncAlert({
title: "Breakout 71", text: `
`, allowClose: true, actions: [
{
text: 'Unlocks',
help: "See and try what you've unlocked",
async value() {
const ts = getTotalScore()
const tryOn = await asyncAlert({
title: 'Your unlocks',
text: `
<p>Your high score is ${highScore}. In total, you've cought ${ts} coins. Click an upgrade below to start a test run with it (stops after 1 level).</p>
`,
actions: [...getPossibleUpgrades()
.sort((a,b)=>a.minimumTotalScore-b.minimumTotalScore)
.map(({
originalMax,
name,
max,
help,id,
minimumTotalScore
}) =>
({
text: name,
help:`${help} (${minimumTotalScore} coins)`,
disabled: !max,
value: {perks: {[id]: 1}}
}))
,
...allLevels.map((l, li) => {
const threshold=levelTotalScoreCondition(l, li)
const avaliable= ts >= threshold
return ({
text: l.name,
help:`A ${l.size}x${l.size} level (${threshold} coins)`,
disabled: !avaliable,
value: {level: l.name}
})
})
]
,
allowClose: true,
})
if (tryOn) {
nextRunOverrides = tryOn
restart()
}
}
},
...optionsList,
(window.screenTop || window.screenY) && {
@ -2237,7 +2288,6 @@ async function openSettingsPanel() {
}
}
}
],
textAfterButtons: `
@ -2254,58 +2304,118 @@ async function openSettingsPanel() {
}
}
function distance2(a,b){
return Math.pow(a.x-b.x,2)+ Math.pow(a.y-b.y,2)
}
function distanceBetween(a,b){
return Math.sqrt(distance2(a,b))
function distance2(a, b) {
return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)
}
function repulse(a,b,power){
function distanceBetween(a, b) {
return Math.sqrt(distance2(a, b))
}
const distance = distanceBetween(a,b)
function rainbowColor() {
return `hsl(${(levelTime / 2) % 360},100%,70%)`
}
function repulse(a, b, power, impactsBToo) {
const distance = distanceBetween(a, b)
// Ensure we don't get soft locked
if(distance>gameZoneWidth/2) return
const max = gameZoneWidth / 2
if (distance > max) return
// Unit vector
const dx= (a.x-b.x)/distance
const dy= (a.y-b.y)/distance
const dx = (a.x - b.x) / distance
const dy = (a.y - b.y) / distance
// TODO
const fact= - power / (1+Math.max(1, distance))
b.vx+=dx*fact
b.vy+=dy*fact
a.vx-=dx*fact
a.vy-=dy*fact
const fact = -power * (max - distance) / (max * 1.2) / 3 * Math.min(500, levelTime) / 500
if (impactsBToo) {
b.vx += dx * fact
b.vy += dy * fact
}
a.vx -= dx * fact
a.vy -= dy * fact
if(!isSettingOn('basic')){
const speed= 10
const rand= 2
if (!isSettingOn('basic')) {
const speed = 10
const rand = 2
flashes.push({
type: "particle",
duration:150,
duration: 100,
time: levelTime,
size:coinSize/2,
color:b.color,
ethereal:true,
x:a.x,
y:a.y,
vx:-dx*speed+a.vx+(Math.random()-0.5)*rand,
vy:-dy*speed+a.vy+(Math.random()-0.5)*rand,
size: coinSize / 2,
color: rainbowColor(),
ethereal: true,
x: a.x,
y: a.y,
vx: -dx * speed + a.vx + (Math.random() - 0.5) * rand,
vy: -dy * speed + a.vy + (Math.random() - 0.5) * rand,
})
if (impactsBToo) {
flashes.push({
type: "particle",
duration: 100,
time: levelTime,
size: coinSize / 2,
color: rainbowColor(),
ethereal: true,
x: b.x,
y: b.y,
vx: dx * speed + b.vx + (Math.random() - 0.5) * rand,
vy: dy * speed + b.vy + (Math.random() - 0.5) * rand,
})
}
}
}
function attract(a, b, power) {
const distance = distanceBetween(a, b)
// Ensure we don't get soft locked
const min = gameZoneWidth * .5
if (distance < min) return
// Unit vector
const dx = (a.x - b.x) / distance
const dy = (a.y - b.y) / distance
const fact = power * (distance - min) / min * Math.min(500, levelTime) / 500
b.vx += dx * fact
b.vy += dy * fact
a.vx -= dx * fact
a.vy -= dy * fact
if (!isSettingOn('basic')) {
const speed = 10
const rand = 2
flashes.push({
type: "particle",
duration: 100,
time: levelTime,
size: coinSize / 2,
color: rainbowColor(),
ethereal: true,
x: a.x,
y: a.y,
vx: dx * speed + a.vx + (Math.random() - 0.5) * rand,
vy: dy * speed + a.vy + (Math.random() - 0.5) * rand,
})
flashes.push({
type: "particle",
duration:150,
duration: 100,
time: levelTime,
size:coinSize/2,
color:a.color,
ethereal:true,
x:b.x,
y:b.y,
vx:dx*speed+b.vx+(Math.random()-0.5)*rand,
vy:dy*speed+b.vy+(Math.random()-0.5)*rand,
size: coinSize / 2,
color: rainbowColor(),
ethereal: true,
x: b.x,
y: b.y,
vx: -dx * speed + b.vx + (Math.random() - 0.5) * rand,
vy: -dy * speed + b.vy + (Math.random() - 0.5) * rand,
})
}
}
fitSize()
restart()
tick();