2025-02-15 19:21:00 +01:00
const MAX _COINS = 400 ;
2025-02-25 21:23:37 +01:00
const MAX _PARTICLES = 600 ;
2025-02-15 19:21:00 +01:00
const canvas = document . getElementById ( "game" ) ;
let ctx = canvas . getContext ( "2d" , { alpha : false } ) ;
let ballSize = 20 ;
const coinSize = Math . round ( ballSize * 0.8 ) ;
const puckHeight = ballSize ;
2025-02-25 21:23:37 +01:00
2025-03-01 14:20:27 +01:00
allLevels . forEach ( l => {
if ( ! l . color && ! l . svg ) {
l . svg = ` <svg version="1.1" viewBox="0 0 200 100" xmlns="http://www.w3.org/2000/svg" width='200' height='100'><text x="0" y="50" fill="white"><tspan> ${ l . name } </tspan></text></svg> `
2025-02-25 21:23:37 +01:00
2025-02-25 14:36:07 +01:00
}
} )
2025-02-15 19:21:00 +01:00
if ( allLevels . find ( l => l . focus ) ) {
allLevels = allLevels . filter ( l => l . focus )
}
2025-02-25 14:36:07 +01:00
2025-02-19 12:37:21 +01:00
// Used to render perk icons
const perkIconsLevels = { }
allLevels = allLevels . filter ( l => {
if ( l . name . startsWith ( 'perk:' ) ) {
perkIconsLevels [ l . name . split ( ':' ) [ 1 ] ] = l
return false
}
return true
} )
2025-02-18 16:03:31 +01:00
allLevels . forEach ( ( l , li ) => {
2025-02-25 00:01:05 +01:00
l . threshold = li < 8 ? 0 : Math . round ( Math . min ( Math . pow ( 10 , 1 + ( li + l . size ) / 30 ) * 10 , 5000 ) * ( li ) )
2025-02-19 21:11:22 +01:00
l . sortKey = ( Math . random ( ) + 3 ) / 3.5 * l . bricks . filter ( i => i ) . length
2025-02-17 10:21:54 +01:00
} )
2025-02-15 19:21:00 +01:00
let runLevels = [ ]
let currentLevel = 0 ;
const bombSVG = document . createElement ( 'img' )
bombSVG . src = 'data:image/svg+xml;base64,' + btoa ( ` <svg width="144" height="144" version="1.1" viewBox="0 0 38.101 38.099" xmlns="http://www.w3.org/2000/svg">
< path d = "m6.1528 26.516c-2.6992-3.4942-2.9332-8.281-.58305-11.981a10.454 10.454 0 017.3701-4.7582c1.962-.27726 4.1646.05953 5.8835.90027l.45013.22017.89782-.87417c.83748-.81464.91169-.87499 1.0992-.90271.40528-.058713.58876.03425 1.1971.6116l.55451.52679 1.0821-1.0821c1.1963-1.1963 1.383-1.3357 2.1039-1.5877.57898-.20223 1.5681-.19816 2.1691.00897 1.4613.50314 2.3673 1.7622 2.3567 3.2773-.0058.95654-.24464 1.5795-.90924 2.3746-.40936.48928-.55533.81057-.57898 1.2737-.02039.41018.1109.77714.42322 1.1792.30172.38816.3694.61323.2797.93044-.12803.45666-.56674.71598-1.0242.60507-.601-.14597-1.3031-1.3088-1.3969-2.3126-.09459-1.0161.19245-1.8682.92392-2.7432.42567-.50885.5643-.82851.5643-1.3031 0-.50151-.14026-.83177-.51211-1.2028-.50966-.50966-1.0968-.64829-1.781-.41996l-.37348.12477-2.1006 2.1006.52597.55696c.45421.48194.5325.58876.57898.78855.09622.41588.07502.45014-.88396 1.4548l-.87173.9125.26339.57979a10.193 10.193 0 01.9231 4.1001c.03996 2.046-.41996 3.8082-1.4442 5.537-.55044.928-1.0185 1.5013-1.8968 2.3241-.83503.78284-1.5526 1.2827-2.4904 1.7361-3.4266 1.657-7.4721 1.3422-10.549-.82035-.73473-.51782-1.7312-1.4621-2.2515-2.1357zm21.869-4.5584c-.0579-.19734-.05871-2.2662 0-2.4545.11906-.39142.57898-.63361 1.0038-.53005.23812.05708.54147.32455.6116.5382.06279.19163.06769 2.1805.0065 2.3811-.12558.40773-.61649.67602-1.0462.57164-.234-.05708-.51615-.30498-.57568-.50722m3.0417-2.6013c-.12313-.6222.37837-1.1049 1.0479-1.0079.18348.0261.25279.08399 1.0071.83911.75838.75838.81301.82362.84074 1.0112.10193.68499-.40365 1.1938-1.034 1.0405-.1949-.0473-.28786-.12558-1.0144-.85216-.7649-.76409-.80241-.81057-.84645-1.0316m.61323-3.0629a.85623.85623 0 01.59284-.99975c.28949-.09214 2.1814-.08318 2.3917.01141.38734.17369.6279.61078.53984.98181-.06035.25606-.35391.57327-.60181.64992-.25279.07747-2.2278.053-2.4097-.03017-.26013-.11906-.46318-.36125-.51374-.61323" fill = "#fff" opacity = "0.3" / >
< / s v g > ` ) ;
// Whatever
let puckWidth = 200 ;
const perks = { } ;
let baseSpeed = 12 ; // applied to x and y
let combo = 1 ;
function baseCombo ( ) {
2025-03-01 14:20:27 +01:00
return 1 + perks . base _combo * 3 + perks . smaller _puck * 5 ;
2025-02-15 19:21:00 +01:00
}
2025-02-17 00:49:03 +01:00
function resetCombo ( x , y ) {
2025-02-15 19:21:00 +01:00
const prev = combo ;
combo = baseCombo ( ) ;
if ( ! levelTime ) {
combo += perks . hot _start * 15 ;
}
if ( prev > combo && perks . soft _reset ) {
combo += Math . floor ( ( prev - combo ) / ( 1 + perks . soft _reset ) )
}
const lost = Math . max ( 0 , prev - combo ) ;
if ( lost ) {
2025-02-23 21:17:22 +01:00
2025-02-15 19:21:00 +01:00
for ( let i = 0 ; i < lost && i < 8 ; i ++ ) {
setTimeout ( ( ) => sounds . comboDecrease ( ) , i * 100 ) ;
}
if ( typeof x !== "undefined" && typeof y !== "undefined" ) {
flashes . push ( {
type : "text" ,
text : "-" + lost ,
time : levelTime ,
color : "red" ,
x : x ,
y : y ,
duration : 150 ,
size : puckHeight ,
} ) ;
}
}
2025-02-19 21:11:22 +01:00
return lost
2025-02-15 19:21:00 +01:00
}
function decreaseCombo ( by , x , y ) {
const prev = combo ;
combo = Math . max ( baseCombo ( ) , combo - by ) ;
const lost = Math . max ( 0 , prev - combo ) ;
if ( lost ) {
sounds . comboDecrease ( ) ;
if ( typeof x !== "undefined" && typeof y !== "undefined" ) {
flashes . push ( {
type : "text" ,
text : "-" + lost ,
time : levelTime ,
color : "red" ,
x : x ,
y : y ,
duration : 300 ,
size : puckHeight ,
} ) ;
}
}
}
let gridSize = 12 ;
2025-02-27 18:56:04 +01:00
let running = false , puck = 400 , pauseTimeout ;
2025-02-17 00:49:03 +01:00
function play ( ) {
if ( running ) return
2025-02-16 21:21:12 +01:00
running = true
2025-02-17 00:49:03 +01:00
if ( audioContext ) {
2025-02-16 21:21:12 +01:00
audioContext . resume ( )
}
2025-02-20 11:34:11 +01:00
resumeRecording ( )
2025-02-16 21:21:12 +01:00
}
2025-02-17 00:49:03 +01:00
2025-02-27 18:56:04 +01:00
function pause ( playerAskedForPause ) {
2025-02-17 00:49:03 +01:00
if ( ! running ) return
2025-02-27 18:56:04 +01:00
if ( pauseTimeout ) return
2025-03-01 14:20:27 +01:00
pauseTimeout = setTimeout ( ( ) => {
2025-02-27 18:56:04 +01:00
running = false
needsRender = true
if ( audioContext ) {
setTimeout ( ( ) => {
if ( ! running )
audioContext . suspend ( )
} , 1000 )
}
pauseRecording ( )
2025-03-01 14:20:27 +01:00
pauseTimeout = null
} , Math . min ( Math . max ( 0 , pauseUsesDuringRun - 5 ) * 50 , 500 ) )
2025-02-27 18:56:04 +01:00
2025-03-01 14:20:27 +01:00
if ( playerAskedForPause ) {
2025-02-27 18:56:04 +01:00
// Pausing many times in a run will make pause slower
pauseUsesDuringRun ++
2025-02-16 21:21:12 +01:00
}
2025-02-27 18:56:04 +01:00
2025-03-01 14:20:27 +01:00
if ( document . exitPointerLock ) {
2025-02-27 19:11:31 +01:00
document . exitPointerLock ( )
}
2025-02-16 21:21:12 +01:00
}
2025-02-18 16:03:31 +01:00
let offsetX , offsetXRoundedDown , gameZoneWidth , gameZoneWidthRoundedUp , gameZoneHeight , brickWidth , needsRender = true ;
2025-02-15 19:21:00 +01:00
const background = document . createElement ( "img" ) ;
const backgroundCanvas = document . createElement ( "canvas" ) ;
background . addEventListener ( "load" , ( ) => {
needsRender = true
} )
2025-02-21 12:41:30 +01:00
2025-02-15 19:21:00 +01:00
const fitSize = ( ) => {
const { width , height } = canvas . getBoundingClientRect ( ) ;
canvas . width = width ;
canvas . height = height ;
2025-02-23 21:17:22 +01:00
ctx . fillStyle = currentLevelInfo ( ) ? . color || 'black'
ctx . globalAlpha = 1
ctx . fillRect ( 0 , 0 , width , height )
2025-02-15 19:21:00 +01:00
backgroundCanvas . width = width ;
backgroundCanvas . height = height ;
gameZoneHeight = isSettingOn ( "mobile-mode" ) ? ( height * 80 ) / 100 : height ;
const baseWidth = Math . round ( Math . min ( canvas . width , gameZoneHeight * 0.73 ) ) ;
brickWidth = Math . floor ( baseWidth / gridSize / 2 ) * 2 ;
gameZoneWidth = brickWidth * gridSize ;
offsetX = Math . floor ( ( canvas . width - gameZoneWidth ) / 2 ) ;
2025-02-18 16:03:31 +01:00
offsetXRoundedDown = offsetX
if ( offsetX < ballSize ) offsetXRoundedDown = 0
gameZoneWidthRoundedUp = width - 2 * offsetXRoundedDown
2025-02-15 19:21:00 +01:00
backgroundCanvas . title = 'resized'
// Ensure puck stays within bounds
setMousePos ( puck ) ;
coins = [ ] ;
flashes = [ ] ;
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-15 19:21:00 +01:00
putBallsAtPuck ( ) ;
2025-02-21 12:41:30 +01:00
// For safari mobile https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
document . documentElement . style . setProperty ( '--vh' , ` ${ window . innerHeight * 0.01 } px ` ) ;
2025-02-15 19:21:00 +01:00
} ;
window . addEventListener ( "resize" , fitSize ) ;
function recomputeTargetBaseSpeed ( ) {
2025-02-19 12:37:21 +01:00
// We never want the ball to completely stop, it will move at least 3px per frame
2025-02-19 21:11:22 +01:00
baseSpeed = Math . max ( 3 , gameZoneWidth / 12 / 10 + currentLevel / 3 + levelTime / ( 30 * 1000 ) - perks . slow _down * 2 ) ;
2025-02-15 19:21:00 +01:00
}
function brickCenterX ( index ) {
return offsetX + ( ( index % gridSize ) + 0.5 ) * brickWidth ;
}
function brickCenterY ( index ) {
return ( Math . floor ( index / gridSize ) + 0.5 ) * brickWidth ;
}
function getRowColIndex ( row , col ) {
if ( row < 0 || col < 0 || row >= gridSize || col >= gridSize ) return - 1 ;
return row * gridSize + col ;
}
2025-02-26 23:36:08 +01:00
2025-02-15 19:21:00 +01:00
function spawnExplosion ( count , x , y , color , duration = 150 , size = coinSize ) {
if ( ! ! isSettingOn ( "basic" ) ) return ;
2025-03-01 14:20:27 +01:00
if ( flashes . length > MAX _PARTICLES ) {
2025-02-25 21:23:37 +01:00
// Avoid freezing when lots of explosion happen at once
count = 1
}
2025-02-15 19:21:00 +01:00
for ( let i = 0 ; i < count ; i ++ ) {
flashes . push ( {
type : "particle" ,
time : levelTime ,
size ,
x : x + ( ( Math . random ( ) - 0.5 ) * brickWidth ) / 2 ,
y : y + ( ( Math . random ( ) - 0.5 ) * brickWidth ) / 2 ,
vx : ( Math . random ( ) - 0.5 ) * 30 ,
vy : ( Math . random ( ) - 0.5 ) * 30 ,
2025-02-19 12:37:21 +01:00
color ,
2025-02-19 21:11:22 +01:00
duration : 150
2025-02-15 19:21:00 +01:00
} ) ;
}
}
let score = 0 ;
let lastexplosion = 0 ;
let highScore = parseFloat ( localStorage . getItem ( "breakout-3-hs" ) || "0" ) ;
let lastPlayedCoinGrab = 0
function addToScore ( coin ) {
coin . destroyed = true
score += coin . points ;
addToTotalScore ( coin . points )
2025-02-17 00:49:03 +01:00
if ( score > highScore && ! hadOverrides ) {
2025-02-15 19:21:00 +01:00
highScore = score ;
localStorage . setItem ( "breakout-3-hs" , score ) ;
}
if ( ! isSettingOn ( 'basic' ) ) {
flashes . push ( {
type : "particle" ,
duration : 100 + Math . random ( ) * 50 ,
time : levelTime ,
size : coinSize / 2 ,
color : coin . color ,
x : coin . previousx ,
y : coin . previousy ,
vx : ( canvas . width - coin . x ) / 100 ,
vy : - coin . y / 100 ,
ethereal : true ,
} )
}
if ( Date . now ( ) - lastPlayedCoinGrab > 16 ) {
lastPlayedCoinGrab = Date . now ( )
sounds . coinCatch ( coin . x )
}
2025-03-01 14:20:27 +01:00
runStatistics . score += coin . points
2025-02-23 21:17:22 +01:00
2025-02-15 19:21:00 +01:00
}
let balls = [ ] ;
2025-03-01 20:40:20 +01:00
let ballsColor = 'white'
2025-02-15 19:21:00 +01:00
function resetBalls ( ) {
const count = 1 + ( perks ? . multiball || 0 ) ;
const perBall = puckWidth / ( count + 1 ) ;
balls = [ ] ;
2025-03-01 20:40:20 +01:00
ballsColor = currentLevelInfo ( ) ? . black _puck ? '#000' : "#FFF"
2025-02-15 19:21:00 +01:00
for ( let i = 0 ; i < count ; i ++ ) {
const x = puck - puckWidth / 2 + perBall * ( i + 1 ) ;
balls . push ( {
x ,
previousx : x ,
y : gameZoneHeight - 1.5 * ballSize ,
previousy : gameZoneHeight - 1.5 * ballSize ,
vx : Math . random ( ) > 0.5 ? baseSpeed : - baseSpeed ,
vy : - baseSpeed ,
sx : 0 ,
sy : 0 ,
sparks : 0 ,
2025-02-21 12:35:42 +01:00
piercedSinceBounce : 0 ,
hitSinceBounce : 0 ,
2025-02-23 21:17:22 +01:00
hitItem : [ ] ,
sapperUses : 0 ,
2025-02-15 19:21:00 +01:00
} ) ;
}
}
function putBallsAtPuck ( ) {
2025-02-21 12:35:42 +01:00
// This reset could be abused to cheat quite easily
2025-02-15 19:21:00 +01:00
const count = balls . length ;
const perBall = puckWidth / ( count + 1 ) ;
balls . forEach ( ( ball , i ) => {
const x = puck - puckWidth / 2 + perBall * ( i + 1 ) ;
2025-03-01 20:40:20 +01:00
ball . x = x
ball . previousx = x
ball . y = gameZoneHeight - 1.5 * ballSize
ball . previousy = ball . y
ball . vx = Math . random ( ) > 0.5 ? baseSpeed : - baseSpeed
ball . vy = - baseSpeed
ball . sx = 0
ball . sy = 0
ball . hitItem = [ ]
ball . hitSinceBounce = 0
ball . piercedSinceBounce = 0
2025-02-15 19:21:00 +01:00
} ) ;
}
resetBalls ( ) ;
// Default, recomputed at each level load
let bricks = [ ] ;
let flashes = [ ] ;
let coins = [ ] ;
let levelStartScore = 0 ;
let levelMisses = 0 ;
let levelSpawnedCoins = 0 ;
2025-02-24 21:04:31 +01:00
function pickedUpgradesHTMl ( ) {
let list = ''
for ( let u of upgrades ) {
for ( let i = 0 ; i < perks [ u . id ] ; i ++ )
list += u . icon + ' '
}
return list
}
async function openUpgradesPicker ( ) {
2025-03-01 14:20:27 +01:00
const catchRate = ( score - levelStartScore ) / ( levelSpawnedCoins || 1 ) ;
2025-02-19 12:37:21 +01:00
2025-02-15 19:21:00 +01:00
let repeats = 1 ;
let choices = 3 ;
2025-02-19 12:37:21 +01:00
let timeGain = '' , catchGain = '' , missesGain = ''
2025-02-15 19:21:00 +01:00
if ( levelTime < 30 * 1000 ) {
repeats ++ ;
choices ++ ;
2025-02-25 21:23:37 +01:00
timeGain = " (+1 upgrade and choice)"
2025-02-15 19:21:00 +01:00
} else if ( levelTime < 60 * 1000 ) {
choices ++ ;
2025-02-19 12:37:21 +01:00
timeGain = " (+1 choice)"
2025-02-15 19:21:00 +01:00
}
if ( catchRate === 1 ) {
repeats ++ ;
choices ++ ;
2025-02-25 21:23:37 +01:00
catchGain = " (+1 upgrade and choice)"
2025-02-15 19:21:00 +01:00
} else if ( catchRate > 0.9 ) {
choices ++ ;
2025-02-19 12:37:21 +01:00
catchGain = " (+1 choice)"
2025-02-15 19:21:00 +01:00
}
if ( levelMisses === 0 ) {
repeats ++ ;
choices ++ ;
2025-02-25 21:23:37 +01:00
missesGain = " (+1 upgrade and choice)"
2025-02-15 19:21:00 +01:00
} else if ( levelMisses <= 3 ) {
choices ++ ;
2025-02-19 12:37:21 +01:00
missesGain = " (+1 choice)"
2025-02-15 19:21:00 +01:00
}
2025-02-19 12:37:21 +01:00
2025-02-15 19:21:00 +01:00
while ( repeats -- ) {
2025-03-01 14:20:27 +01:00
const actions = pickRandomUpgrades ( choices + perks . one _more _choice - perks . instant _upgrade ) ;
2025-02-15 19:21:00 +01:00
if ( ! actions . length ) break
2025-02-24 21:04:31 +01:00
let textAfterButtons = `
< p > Upgrades picked so far : < / p > < p > $ { p i c k e d U p g r a d e s H T M l ( ) } < / p >
< div id = "level-recording-container" > < / d i v >
` ;
2025-02-19 12:37:21 +01:00
2025-02-25 21:23:37 +01:00
const upgradeId = await asyncAlert ( {
2025-02-24 21:04:31 +01:00
title : "Pick an upgrade " + ( repeats ? "(" + ( repeats + 1 ) + ")" : "" ) , actions ,
text : ` <p>
You caught $ { score - levelStartScore } coins $ { catchGain } out of $ { levelSpawnedCoins } in $ { Math . round ( levelTime / 1000 ) } seconds$ { timeGain } .
You missed $ { levelMisses } times $ { missesGain } . < / p > ` ,
allowClose : false ,
2025-02-15 19:21:00 +01:00
textAfterButtons
} ) ;
2025-02-25 21:23:37 +01:00
perks [ upgradeId ] ++ ;
2025-03-01 14:20:27 +01:00
if ( upgradeId === 'instant_upgrade' ) {
repeats += 2
2025-02-25 21:23:37 +01:00
}
2025-02-23 21:17:22 +01:00
runStatistics . upgrades _picked ++
2025-02-15 19:21:00 +01:00
}
resetCombo ( ) ;
resetBalls ( ) ;
}
function setLevel ( l ) {
2025-02-27 18:56:04 +01:00
pause ( false )
2025-02-15 19:21:00 +01:00
if ( l > 0 ) {
openUpgradesPicker ( ) . then ( ) ;
}
currentLevel = l ;
levelTime = 0 ;
lastTickDown = levelTime ;
levelStartScore = score ;
levelSpawnedCoins = 0 ;
levelMisses = 0 ;
2025-02-23 21:17:22 +01:00
runStatistics . levelsPlayed ++
2025-02-15 19:21:00 +01:00
resetCombo ( ) ;
recomputeTargetBaseSpeed ( ) ;
resetBalls ( ) ;
const lvl = currentLevelInfo ( ) ;
if ( lvl . size !== gridSize ) {
gridSize = lvl . size ;
fitSize ( ) ;
}
coins = [ ] ;
bricks = [ ... lvl . bricks ] ;
flashes = [ ] ;
2025-02-25 21:23:37 +01:00
// This caused problems with accented characters like the ô of côte d'ivoire for odd reasons
// background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg)
2025-03-01 14:20:27 +01:00
background . src = 'data:image/svg+xml;UTF8,' + lvl . svg
2025-02-20 11:34:11 +01:00
stopRecording ( )
startRecordingGame ( )
2025-02-15 19:21:00 +01:00
}
function currentLevelInfo ( ) {
return runLevels [ currentLevel % runLevels . length ] ;
}
function reset _perks ( ) {
for ( let u of upgrades ) {
perks [ u . id ] = 0 ;
}
2025-02-17 00:49:03 +01:00
if ( nextRunOverrides . perks ) {
const first = Object . keys ( nextRunOverrides . perks ) [ 0 ]
Object . assign ( perks , nextRunOverrides . perks )
nextRunOverrides . perks = null
return first
2025-02-15 19:21:00 +01:00
}
2025-02-17 01:07:20 +01:00
const giftable = getPossibleUpgrades ( ) . filter ( u => u . giftable )
2025-02-15 19:21:00 +01:00
const randomGift = isSettingOn ( 'easy' ) ? 'slow_down' : giftable [ Math . floor ( Math . random ( ) * giftable . length ) ] . id ;
perks [ randomGift ] = 1 ;
2025-02-16 21:21:12 +01:00
2025-02-15 19:21:00 +01:00
return randomGift
}
2025-02-17 00:49:03 +01:00
const upgrades = [
{
2025-02-17 01:07:20 +01:00
"threshold" : 0 ,
"id" : "extra_life" ,
"name" : "+1 life" ,
2025-02-19 12:37:21 +01:00
"max" : 7 ,
2025-02-23 21:17:22 +01:00
"help" : "Survive dropping the ball" ,
2025-03-01 14:20:27 +01:00
extraLevelsHelp : ` One more life just in case ` ,
fullHelp : ` Normally, you just have one life, and the run is over as soon as you drop it.
With this perk , you can survive dropping the ball once . A heart in the top right corner will remind you of how many extra lives you have . `
2025-02-17 01:07:20 +01:00
} ,
2025-02-15 19:21:00 +01:00
{
2025-02-17 01:07:20 +01:00
"threshold" : 0 ,
"id" : "streak_shots" ,
"giftable" : true ,
"name" : "Single puck hit streak" ,
"max" : 1 ,
2025-03-01 16:25:15 +01:00
"help" : "More coins if you break many bricks at once." ,
2025-03-01 14:20:27 +01:00
fullHelp : ` Every time you break a brick, your combo (number of coins per bricks) increases by one. However, as soon as the ball touches your puck,
2025-03-01 16:25:15 +01:00
the combo is reset to its default value , and you ' ll just get one coin per brick . So you should try to hit many bricks in one go for more score .
2025-03-01 14:20:27 +01:00
Once 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 .
This 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 . `
2025-02-15 19:21:00 +01:00
} ,
{
2025-02-17 01:07:20 +01:00
"threshold" : 0 ,
"id" : "base_combo" ,
"giftable" : true ,
"name" : "+3 base combo" ,
2025-02-19 12:37:21 +01:00
"max" : 7 ,
2025-03-01 14:20:27 +01:00
"help" : "3 more coins from every brick." ,
extraLevelsHelp : ` Combo starts 3 points higher ` ,
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 . `
2025-02-15 19:21:00 +01:00
} ,
{
2025-02-17 01:07:20 +01:00
"threshold" : 0 ,
"id" : "slow_down" ,
"name" : "Slower ball" ,
"max" : 2 ,
2025-02-26 21:03:39 +01:00
"help" : "slow down the ball" ,
2025-03-01 14:20:27 +01:00
extraLevelsHelp : ` Make it even slower ` ,
fullHelp : ` The ball starts relatively slow, but every level of your run it will start a bit faster, and it will also accelerate if you spend a lot of time in one level. This perk makes it
more manageable . You can get it at the start every time by enabling kid mode in the menu . `
2025-02-15 19:21:00 +01:00
} ,
{
2025-02-17 01:07:20 +01:00
"threshold" : 0 ,
"id" : "bigger_puck" ,
"name" : "Bigger puck" ,
"max" : 2 ,
2025-02-23 21:17:22 +01:00
"help" : "Catches more coins" ,
2025-03-01 14:20:27 +01:00
extraLevelsHelp : ` Even bigger puck ` ,
fullHelp : ` A bigger puck makes it easier to never miss the ball and to catch more coins, and also to precisely angle the bounces (the ball's angle only depends on where it hits the puck).
However , a large puck is harder to use around the sides of the level , and will make it sometimes unavoidable to miss ( not hit anything ) which comes with downsides . `
2025-02-15 19:21:00 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 0 ,
2025-02-17 01:07:20 +01:00
"id" : "viscosity" ,
2025-02-19 21:11:22 +01:00
"name" : "Viscosity" ,
2025-02-17 01:07:20 +01:00
"max" : 3 ,
2025-02-23 21:17:22 +01:00
"help" : "Slower coins fall" ,
extraLevelsHelp : ` Even slower fall ` ,
2025-02-19 21:11:22 +01:00
tryout : {
perks : { viscosity : 3 , base _combo : 3 } ,
level : 'Waves'
2025-03-01 14:20:27 +01:00
} ,
fullHelp : ` Coins normally accelerate with gravity and explosions to pretty high speeds. This perk constantly makes them slow down, as if they were in some sort of viscous liquid.
This makes catching them easier , and combines nicely with perks that influence the coin ' s movement . `
2025-02-15 19:21:00 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 0 ,
2025-02-17 01:07:20 +01:00
"id" : "sides_are_lava" ,
"giftable" : true ,
"name" : "Shoot straight" ,
"max" : 1 ,
2025-03-01 16:25:15 +01:00
"help" : "More coins if you don't touch the sides." ,
2025-03-01 14:20:27 +01:00
fullHelp : ` Whenever you break a brick, your combo will increase by one, so you'll get one more coin all the next bricks you break.
However , your combo will reset as soon as your ball hits the left or right side .
As soon as your combo rises , the sides become red to remind you that you should avoid hitting them . The effect stacks with other combo perks , combo rises faster with more upgrades but will also reset if any
of the reset conditions are met . `
2025-02-15 19:21:00 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 0 ,
2025-02-17 01:07:20 +01:00
"id" : "top_is_lava" ,
"giftable" : true ,
"name" : "Sky is the limit" ,
"max" : 1 ,
2025-03-01 16:25:15 +01:00
"help" : "More coins if you don't touch the top." ,
2025-03-01 14:20:27 +01:00
fullHelp : ` Whenever you break a brick, your combo will increase by one. However, your combo will reset as soon as your ball hit the top of the screen.
When your combo is above the minimum , a red bar will appear at the top to remind you that you should avoid hitting it .
The effect stacks with other combo perks . `
2025-02-15 19:21:00 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 0 ,
2025-02-17 01:07:20 +01:00
"id" : "skip_last" ,
2025-02-19 21:11:22 +01:00
"name" : "Easy Cleanup" ,
2025-02-19 12:37:21 +01:00
"max" : 7 ,
2025-02-23 21:17:22 +01:00
"help" : "The last brick will self-destruct" ,
extraLevelsHelp : ` Level clears one brick earlier ` ,
2025-03-01 14:20:27 +01:00
fullHelp : ` You need to break all bricks to go to the next level. However, it can be hard to get the last ones.
Clearing a level early brings extra choices when upgrading . Never missing the bricks is also very beneficial .
2025-03-01 16:25:15 +01:00
So if you find it difficult to break the last bricks , getting this perk a few time can help . `
2025-02-15 19:21:00 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 500 ,
"id" : "telekinesis" ,
"giftable" : true ,
"name" : "Puck controls ball" ,
"max" : 2 ,
2025-02-23 21:17:22 +01:00
"help" : "Control the ball's trajectory" ,
extraLevelsHelp : ` Stronger effect on the ball ` ,
2025-03-01 14:20:27 +01:00
fullHelp : ` Right after the ball hits your puck, you'll be able to direct it left and right by moving your puck.
The effect stops when the ball hits a brick and resets the next time it touches the puck . It also does nothing when the ball is going downward after bouncing at the top . `
2025-02-19 12:37:21 +01:00
} ,
{
"threshold" : 1000 ,
"id" : "coin_magnet" ,
2025-02-19 21:11:22 +01:00
"name" : "Coins magnet" ,
2025-02-19 12:37:21 +01:00
"max" : 3 ,
2025-02-23 21:17:22 +01:00
"help" : "Puck attracts coins" ,
2025-02-19 21:11:22 +01:00
tryout : {
perks : { coin _magnet : 3 , base _combo : 3 }
2025-03-01 14:20:27 +01:00
} ,
extraLevelsHelp : ` Stronger effect on the coins ` ,
fullHelp : ` Directs the coins to the puck. The effect is stronger if the coin is close to it already. Catching 90% or 100% of coins bring special bonuses in the game.
2025-03-01 16:25:15 +01:00
Another way to catch more coins is to hit bricks from the bottom . The ball 's speed and direction impacts the spawned coin' s velocity . `
2025-02-19 12:37:21 +01:00
} ,
{
"threshold" : 1500 ,
2025-02-17 01:07:20 +01:00
"id" : "multiball" ,
"giftable" : true ,
"name" : "+1 ball" ,
2025-02-23 21:17:22 +01:00
"max" : 6 ,
"help" : "Start with two balls" ,
extraLevelsHelp : ` One more ball ` ,
2025-03-01 16:25:15 +01:00
fullHelp : ` As soon as you drop the ball in Breakout 71, you loose. With this perk, you get two balls, and so you can afford to lose one.
The lost balls come back on the next level or whenever you use one of your extra lives , if you picked that perk . Having more than one balls makes
some further perks available , and of course clears the level faster . `
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 2000 ,
2025-02-17 01:07:20 +01:00
"id" : "smaller_puck" ,
"name" : "Smaller puck" ,
"max" : 2 ,
2025-03-01 14:20:27 +01:00
"help" : "Also gives +5 base combo" ,
extraLevelsHelp : ` Even smaller puck and higher base combo ` ,
fullHelp : ` This makes the puck smaller, which in theory makes some corner shots easier, but really just raises the difficulty.
That 's why you also get a nice bonus of +5 coins per brick for all bricks you' ll break after picking this . `
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 3000 ,
2025-02-17 01:07:20 +01:00
"id" : "pierce" ,
"giftable" : true ,
2025-02-23 21:17:22 +01:00
"name" : "Piercing" ,
2025-02-17 01:07:20 +01:00
"max" : 3 ,
2025-02-23 21:17:22 +01:00
"help" : "Ball pierces 3 bricks" ,
extraLevelsHelp : ` Pierce 3 more bricks ` ,
2025-03-01 16:25:15 +01:00
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.
After that , it will bounce on the 4 th brick , and you ' ll need to touch the puck to reset the counter . This combines particularly well with Sapper . `
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 4000 ,
2025-02-17 01:07:20 +01:00
"id" : "picky_eater" ,
"giftable" : true ,
2025-02-19 21:11:22 +01:00
"name" : "Picky eater" ,
2025-02-17 01:07:20 +01:00
"max" : 1 ,
2025-03-01 16:25:15 +01:00
"help" : "More coins if you break bricks color by color." ,
2025-02-19 21:11:22 +01:00
tryout : {
perks : { picky _eater : 1 } ,
level : 'Mountain'
2025-03-01 14:20:27 +01:00
} ,
2025-03-01 20:40:20 +01:00
fullHelp : ` Whenever you break a brick the same color as your ball, your combo increases by one.
If it ' s a different color , the ball takes that new color , but the combo resets .
The bricks with the right color will get a white border .
Once you get a combo higher than your minimum , the bricks of the wrong color will get a red halo .
If you have more than one ball , they all change color whenever one of them hits a brick .
2025-03-01 14:20:27 +01:00
`
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 5000 ,
2025-02-17 01:07:20 +01:00
"id" : "metamorphosis" ,
2025-02-19 21:11:22 +01:00
"name" : "Stain" ,
2025-02-17 01:07:20 +01:00
"max" : 1 ,
2025-02-23 21:17:22 +01:00
"help" : "Coins color the bricks they touch" ,
2025-02-19 21:11:22 +01:00
tryout : {
perks : { metamorphosis : 3 } ,
level : 'Lines'
2025-03-01 14:20:27 +01:00
} ,
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" .
`
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 6000 ,
2025-03-01 20:40:20 +01:00
"id" : "compound_interest" ,
2025-02-17 01:07:20 +01:00
"giftable" : true ,
"name" : "Compound interest" ,
"max" : 3 ,
2025-03-01 16:25:15 +01:00
"help" : "More coins if you catch them all." ,
extraLevelsHelp : ` Combo grows faster, but missed coins hurt it more ` ,
2025-03-01 14:20:27 +01:00
fullHelp : ` Your combo will grow by one every time you break a brick, spawning more and more coin with every brick you break. Be sure however to catch every one of those coins
2025-03-01 16:25:15 +01:00
with your puck , as any lost coin will decrease your combo by one point . One your combo is above the minimum , the bottom of the play area will
have a red line to remind you that coins should not go there . This perk combines with other combo perks , the combo will rise faster but reset more easily .
2025-03-01 14:20:27 +01:00
`
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 7000 ,
2025-02-17 01:07:20 +01:00
"id" : "hot_start" ,
"giftable" : true ,
"name" : "Hot start" ,
"max" : 3 ,
2025-03-01 14:20:27 +01:00
"help" : "More coins for 15s." ,
2025-02-23 21:17:22 +01:00
extraLevelsHelp : ` Combo starts higher but shrinks faster ` ,
2025-03-01 14:20:27 +01:00
fullHelp : ` At the start of every level, your combo will start at +15 points, but then every second it will be decreased by one. This means the first 15 seconds in a level will spawn
2025-03-01 16:25:15 +01:00
many more coins than the following ones , and you should make sure that you clear the level quickly . The effect stacks with other combo related perks , so you might be able to raise
2025-03-01 14:20:27 +01:00
the combo after the 15 s timeout , but it will keep ticking down . Every time you take the perk again , the effect will be more dramatic .
`
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 9000 ,
2025-02-17 01:07:20 +01:00
"id" : "sapper" ,
"giftable" : true ,
2025-02-19 21:11:22 +01:00
"name" : "Sapper" ,
2025-02-23 21:17:22 +01:00
"max" : 7 ,
"help" : "1st brick hit becomes bomb" ,
extraLevelsHelp : ` 1 more brick replaced by a bomb ` ,
2025-03-01 16:25:15 +01:00
fullHelp : ` Instead of just disappearing, the first brick you break will be replaced by a bomb brick. Bouncing the ball on the puck re-arms the effect. "Piercing" will instantly
detonate the bomb that was just placed . Leveling - up this perk will allow you to place more bombs . Remember that bombs impact the velocity of nearby coins , so too many explosions
2025-03-01 14:20:27 +01:00
could make it hard to catch the fruits of your hard work .
`
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 11000 ,
2025-02-17 01:07:20 +01:00
"id" : "bigger_explosions" ,
2025-02-19 21:11:22 +01:00
"name" : "Kaboom" ,
2025-02-17 01:07:20 +01:00
"max" : 1 ,
2025-02-23 21:17:22 +01:00
"help" : "Bigger explosions" ,
2025-02-19 21:11:22 +01:00
tryout : {
perks : { bigger _explosions : 1 } ,
level : 'Ship'
2025-03-01 14:20:27 +01:00
} ,
fullHelp : ` The default explosion clears a 3x3 square, with this it becomes a 5x5 square, and the blowback on the coins is also significantly stronger. `
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 13000 ,
2025-02-17 01:07:20 +01:00
"id" : "extra_levels" ,
"name" : "+1 level" ,
"max" : 3 ,
2025-02-23 21:17:22 +01:00
"help" : "Play 8 levels instead of 7" ,
2025-02-27 20:17:06 +01:00
extraLevelsHelp : ` 1 more level to play ` ,
2025-03-01 14:20:27 +01:00
fullHelp : ` The default run can last a max of 7 levels, after which the game is over and whatever score you reached is your run score.
2025-03-01 16:25:15 +01:00
Each level of this perk lets you go one level higher . The last levels are often the ones where you make the most score , so the difference can be dramatic . `
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 15000 ,
2025-02-17 01:07:20 +01:00
"id" : "pierce_color" ,
"name" : "Color pierce" ,
"max" : 1 ,
2025-03-01 20:40:20 +01:00
"help" : "Balls pierce bricks of their color" ,
2025-03-01 14:20:27 +01:00
fullHelp : ` Whenever a ball hits a brick of the same color, it will just go through unimpeded.
Once it reaches a brick of a different color , it will break it , take its color and bounce . `
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 18000 ,
2025-02-17 01:07:20 +01:00
"id" : "soft_reset" ,
"name" : "Soft reset" ,
"max" : 2 ,
2025-02-23 21:17:22 +01:00
"help" : "Combo grows slower but resets less" ,
extraLevelsHelp : ` Even slower combo growth but softer reset ` ,
2025-03-01 14:20:27 +01:00
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. `
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 21000 ,
2025-02-17 01:07:20 +01:00
"id" : "ball_repulse_ball" ,
2025-02-19 21:11:22 +01:00
"name" : "Personal space" ,
2025-02-18 16:03:31 +01:00
requires : 'multiball' ,
2025-02-17 01:07:20 +01:00
"max" : 3 ,
2025-02-19 21:11:22 +01:00
"help" : "Balls repulse balls." ,
2025-02-23 21:17:22 +01:00
extraLevelsHelp : 'Stronger repulsion force ' ,
2025-02-19 21:11:22 +01:00
tryout : {
perks : { ball _repulse _ball : 1 , multiball : 2 } ,
2025-03-01 14:20:27 +01:00
} ,
fullHelp : ` Balls that are less than half a screen width away will start repulsing each other. The repulsion force is stronger if they are close to each other.
2025-03-01 16:25:15 +01:00
Particles will jet out to symbolize this force being applied . This perk is only offered if you have more than one ball already . `
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 25000 ,
2025-02-17 01:07:20 +01:00
"id" : "ball_attract_ball" ,
2025-02-18 16:03:31 +01:00
requires : 'multiball' ,
2025-02-19 21:11:22 +01:00
"name" : "Gravity" ,
2025-02-17 01:07:20 +01:00
"max" : 3 ,
2025-02-23 21:17:22 +01:00
"help" : "Balls attract balls." , extraLevelsHelp : 'Stronger attraction force ' ,
2025-02-19 21:11:22 +01:00
tryout : {
perks : { ball _attract _ball : 1 , multiball : 2 } ,
2025-03-01 14:20:27 +01:00
} ,
fullHelp : ` Balls that are more than half a screen width away will start attracting each other. The attraction force is stronger when they are furthest away from each other.
2025-03-01 16:25:15 +01:00
Rainbow particles will fly to symbolize the attraction force . This perk is only offered if you have more than one ball already . `
2025-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 30000 ,
2025-02-17 01:07:20 +01:00
"id" : "puck_repulse_ball" ,
2025-02-19 21:11:22 +01:00
"name" : "Soft landing" ,
2025-02-23 21:17:22 +01:00
extraLevelsHelp : 'Stronger repulsion force ' ,
2025-02-17 01:07:20 +01:00
"max" : 3 ,
2025-02-19 21:11:22 +01:00
"help" : "Puck repulses balls." ,
2025-03-01 14:20:27 +01:00
fullHelp : ` When a ball gets close to the perk, it will start slowing down, and even potentially bouncing without touching the puck. `
2025-02-17 01:07:20 +01:00
} ,
2025-02-19 22:06:29 +01:00
{
"threshold" : 35000 ,
"id" : "wind" ,
"name" : "Wind" ,
"max" : 3 ,
2025-03-01 14:20:27 +01:00
"help" : "Puck position creates wind." ,
extraLevelsHelp : 'Stronger wind force ' ,
2025-03-01 16:25:15 +01:00
fullHelp : ` The wind depends on where your puck is, if it's in the center of the screen nothing happens, if it's on the left it will blow leftwise, if it's on the right of the screen
2025-03-01 14:20:27 +01:00
then it will blow rightwise . The wind affects both the balls and coins . `
2025-02-19 22:06:29 +01:00
} ,
2025-02-21 12:35:42 +01:00
{
"threshold" : 40000 ,
"id" : "sturdy_bricks" ,
"name" : "Sturdy bricks" ,
"max" : 4 ,
"help" : "Bricks sometimes resist hits but drop more coins." ,
2025-02-23 21:17:22 +01:00
extraLevelsHelp : 'Bricks resist more and drop more coins ' ,
2025-03-01 14:20:27 +01:00
fullHelp : ` With level one of this perk, the ball has a 20% chance to bounce harmlessly on bricks,
but generates 10 % more coins when it does break one .
This + 10 % is not shown in the combo number . At level 4 the ball has 80 % chance of bouncing and brings 40 % more coins . `
2025-02-21 12:35:42 +01:00
} ,
{
"threshold" : 45000 ,
"id" : "respawn" ,
"name" : "Respawn" ,
"max" : 4 ,
2025-03-01 14:20:27 +01:00
"help" : "The first brick hit of two+ will respawn." ,
extraLevelsHelp : 'More bricks can respawn ' ,
fullHelp : ` After breaking two or more bricks, when the ball hits the puck, the first brick will be put back in place, provided that space is free and the brick wasn't a bomb.
2025-03-01 16:25:15 +01:00
Some particle effect will let you know where bricks will appear . Levelling this up lets you respawn up to 4 bricks at a time , but there should always be at least one destroyed .
2025-03-01 14:20:27 +01:00
`
2025-02-21 12:35:42 +01:00
} ,
2025-02-25 21:23:37 +01:00
{
"threshold" : 50000 ,
"id" : "one_more_choice" ,
"name" : "+1 choice permanently" ,
"max" : 3 ,
"help" : "Further level ups will offer one more option in the list" ,
extraLevelsHelp : 'Even more options ' ,
2025-03-01 16:25:15 +01:00
fullHelp : ` Every upgrade menu will have one more option.
2025-03-01 14:20:27 +01:00
Doesn ' t increase the number of upgrades you can pick .
`
2025-02-25 21:23:37 +01:00
} ,
{
"threshold" : 55000 ,
"id" : "instant_upgrade" ,
"name" : "+2 upgrades now" ,
"max" : 2 ,
"help" : "-1 choice permanently" ,
extraLevelsHelp : 'Even fewer options ' ,
2025-03-01 14:20:27 +01:00
fullHelp : ` Immediately pick two upgrades, so that you get one free one and one to repay the one used to get this perk.
Every further menu to pick upgrades will have fewer options to choose from .
`
2025-02-25 21:23:37 +01:00
} ,
2025-02-17 00:49:03 +01:00
]
2025-02-15 19:21:00 +01:00
2025-02-19 12:37:21 +01:00
let totalScoreAtRunStart = getTotalScore ( )
2025-02-15 19:21:00 +01:00
function getPossibleUpgrades ( ) {
return upgrades
2025-02-19 00:19:40 +01:00
. filter ( u => totalScoreAtRunStart >= u . threshold )
2025-02-17 14:31:43 +01:00
. filter ( u => ! u . requires || perks [ u . requires ] )
2025-02-15 19:21:00 +01:00
}
2025-02-19 12:37:21 +01:00
function shuffleLevels ( nameToAvoid = null ) {
2025-02-19 21:11:22 +01:00
const target = nextRunOverrides ? . level ;
if ( target ) {
runLevels = allLevels . filter ( l => l . name === target )
nextRunOverrides . level = null
if ( runLevels . length ) return
}
2025-02-19 12:37:21 +01:00
2025-02-15 19:21:00 +01:00
runLevels = allLevels
2025-02-19 00:19:40 +01:00
. filter ( ( l , li ) => totalScoreAtRunStart >= l . threshold )
2025-02-15 19:21:00 +01:00
. filter ( l => l . name !== nameToAvoid || allLevels . length === 1 )
. sort ( ( ) => Math . random ( ) - 0.5 )
. slice ( 0 , 7 + 3 )
2025-02-19 12:37:21 +01:00
. sort ( ( a , b ) => a . sortKey - b . sortKey ) ;
2025-02-17 00:49:03 +01:00
2025-02-15 19:21:00 +01:00
}
function getUpgraderUnlockPoints ( ) {
let list = [ ]
upgrades
. forEach ( u => {
2025-02-17 01:07:20 +01:00
if ( u . threshold ) {
2025-02-15 19:21:00 +01:00
list . push ( {
2025-02-17 01:07:20 +01:00
threshold : u . threshold ,
2025-02-19 12:37:21 +01:00
title : u . name + ' (Perk)'
2025-02-15 19:21:00 +01:00
} )
}
} )
allLevels . forEach ( ( l , li ) => {
list . push ( {
2025-02-17 10:21:54 +01:00
threshold : l . threshold ,
2025-02-17 00:49:03 +01:00
title : l . name + ' (Level)' ,
2025-02-15 19:21:00 +01:00
} )
} )
return list . filter ( o => o . threshold ) . sort ( ( a , b ) => a . threshold - b . threshold )
}
2025-02-18 16:03:31 +01:00
let lastOffered = { }
2025-02-19 22:06:29 +01:00
function dontOfferTooSoon ( id ) {
2025-02-19 21:11:22 +01:00
lastOffered [ id ] = Math . round ( Date . now ( ) / 1000 )
}
2025-02-15 19:21:00 +01:00
function pickRandomUpgrades ( count ) {
let list = getPossibleUpgrades ( )
2025-02-18 16:03:31 +01:00
. map ( u => ( { ... u , score : Math . random ( ) + ( lastOffered [ u . id ] || 0 ) } ) )
. sort ( ( a , b ) => a . score - b . score )
2025-02-15 19:21:00 +01:00
. filter ( u => perks [ u . id ] < u . max )
. slice ( 0 , count )
. sort ( ( a , b ) => a . id > b . id ? 1 : - 1 )
2025-02-18 16:03:31 +01:00
list . forEach ( u => {
2025-02-19 21:11:22 +01:00
dontOfferTooSoon ( u . id )
2025-02-17 14:31:43 +01:00
} )
2025-02-19 12:37:21 +01:00
return list . map ( u => ( {
2025-02-19 21:11:22 +01:00
text : u . name + ( perks [ u . id ] ? ' lvl ' + ( perks [ u . id ] + 1 ) : '' ) ,
2025-02-19 12:37:21 +01:00
icon : u . icon ,
2025-03-01 14:20:27 +01:00
value : u . id ,
2025-02-23 21:17:22 +01:00
help : ( perks [ u . id ] && u . extraLevelsHelp ) || u . help ,
2025-02-19 12:37:21 +01:00
// max: u.max,
// checked: perks[u.id]
} ) )
2025-02-15 19:21:00 +01:00
}
2025-02-17 00:49:03 +01:00
let nextRunOverrides = { level : null , perks : null }
2025-03-01 14:20:27 +01:00
let hadOverrides = false , pauseUsesDuringRun = 0
2025-02-15 19:21:00 +01:00
function restart ( ) {
2025-02-17 00:49:03 +01:00
hadOverrides = ! ! ( nextRunOverrides . level || nextRunOverrides . perks )
2025-02-15 19:21:00 +01:00
// When restarting, we want to avoid restarting with the same level we're on, so we exclude from the next
// run's level list
2025-02-19 12:37:21 +01:00
totalScoreAtRunStart = getTotalScore ( )
2025-02-15 19:21:00 +01:00
shuffleLevels ( levelTime || score ? currentLevelInfo ( ) . name : null ) ;
resetRunStatistics ( )
score = 0 ;
2025-03-01 14:20:27 +01:00
pauseUsesDuringRun = 0
2025-02-24 21:04:31 +01:00
2025-02-15 19:21:00 +01:00
const randomGift = reset _perks ( ) ;
2025-02-19 22:06:29 +01:00
dontOfferTooSoon ( randomGift )
2025-02-15 19:21:00 +01:00
setLevel ( 0 ) ;
2025-02-20 11:34:11 +01:00
pauseRecording ( )
2025-02-15 19:21:00 +01:00
}
2025-03-01 14:20:27 +01:00
let keyboardPuckSpeed = 0
2025-02-15 19:21:00 +01:00
function setMousePos ( x ) {
needsRender = true ;
puck = x ;
2025-02-18 16:03:31 +01:00
// We have borders visible, enforce them
if ( puck < offsetXRoundedDown + puckWidth / 2 ) {
puck = offsetXRoundedDown + puckWidth / 2 ;
}
if ( puck > offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2 ) {
puck = offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2 ;
2025-02-15 19:21:00 +01:00
}
if ( ! running && ! levelTime ) {
putBallsAtPuck ( ) ;
}
}
canvas . addEventListener ( "mouseup" , ( e ) => {
if ( e . button !== 0 ) return ;
2025-02-17 00:49:03 +01:00
if ( running ) {
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-17 00:49:03 +01:00
} else {
2025-02-16 21:21:12 +01:00
play ( )
2025-03-01 14:20:27 +01:00
if ( isSettingOn ( 'pointerLock' ) ) {
2025-02-27 19:11:31 +01:00
canvas . requestPointerLock ( )
}
2025-02-16 21:21:12 +01:00
}
2025-02-15 19:21:00 +01:00
} ) ;
canvas . addEventListener ( "mousemove" , ( e ) => {
2025-03-01 14:20:27 +01:00
if ( document . pointerLockElement === canvas ) {
setMousePos ( puck + e . movementX ) ;
} else {
setMousePos ( e . x ) ;
2025-02-27 19:11:31 +01:00
}
2025-02-15 19:21:00 +01:00
} ) ;
canvas . addEventListener ( "touchstart" , ( e ) => {
e . preventDefault ( ) ;
if ( ! e . touches ? . length ) return ;
setMousePos ( e . touches [ 0 ] . pageX ) ;
2025-02-16 21:21:12 +01:00
play ( )
2025-02-15 19:21:00 +01:00
} ) ;
canvas . addEventListener ( "touchend" , ( e ) => {
e . preventDefault ( ) ;
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-15 19:21:00 +01:00
} ) ;
canvas . addEventListener ( "touchcancel" , ( e ) => {
e . preventDefault ( ) ;
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-15 19:21:00 +01:00
needsRender = true
} ) ;
canvas . addEventListener ( "touchmove" , ( e ) => {
if ( ! e . touches ? . length ) return ;
setMousePos ( e . touches [ 0 ] . pageX ) ;
} ) ;
let lastTick = performance . now ( ) ;
function brickIndex ( x , y ) {
2025-02-26 23:03:12 +01:00
return getRowColIndex ( Math . floor ( y / brickWidth ) , Math . floor ( ( x - offsetX ) / brickWidth ) )
2025-02-15 19:21:00 +01:00
}
function hasBrick ( index ) {
if ( bricks [ index ] ) return index ;
}
function hitsSomething ( x , y , radius ) {
return ( hasBrick ( brickIndex ( x - radius , y - radius ) ) ? ? hasBrick ( brickIndex ( x + radius , y - radius ) ) ? ? hasBrick ( brickIndex ( x + radius , y + radius ) ) ? ? hasBrick ( brickIndex ( x - radius , y + radius ) ) ) ;
}
2025-03-01 20:40:20 +01:00
function shouldPierceByColor ( vhit , hhit , chit ) {
2025-02-15 19:21:00 +01:00
if ( ! perks . pierce _color ) return false
2025-03-01 20:40:20 +01:00
if ( typeof vhit !== 'undefined' && bricks [ vhit ] !== ballsColor ) {
2025-02-15 19:21:00 +01:00
return false
}
2025-03-01 20:40:20 +01:00
if ( typeof hhit !== 'undefined' && bricks [ hhit ] !== ballsColor ) {
2025-02-15 19:21:00 +01:00
return false
}
2025-03-01 20:40:20 +01:00
if ( typeof chit !== 'undefined' && bricks [ chit ] !== ballsColor ) {
2025-02-15 19:21:00 +01:00
return false
}
return true
}
function brickHitCheck ( ballOrCoin , radius , isBall ) {
// Make ball/coin bonce, and return bricks that were hit
2025-02-17 17:52:20 +01:00
const { x , y , previousx , previousy } = ballOrCoin ;
2025-02-15 19:21:00 +01:00
const vhit = hitsSomething ( previousx , y , radius ) ;
const hhit = hitsSomething ( x , previousy , radius ) ;
const chit = ( typeof vhit == "undefined" && typeof hhit == "undefined" && hitsSomething ( x , y , radius ) ) || undefined ;
let pierce = isBall && ballOrCoin . piercedSinceBounce < perks . pierce * 3 ;
if ( pierce && ( typeof vhit !== "undefined" || typeof hhit !== "undefined" || typeof chit !== "undefined" ) ) {
ballOrCoin . piercedSinceBounce ++
}
2025-03-01 20:40:20 +01:00
if ( isBall && shouldPierceByColor ( vhit , hhit , chit ) ) {
2025-02-15 19:21:00 +01:00
pierce = true
}
if ( typeof vhit !== "undefined" || typeof chit !== "undefined" ) {
if ( ! pierce ) {
ballOrCoin . y = ballOrCoin . previousy ;
ballOrCoin . vy *= - 1 ;
}
if ( ! isBall ) {
// Roll on corners
const leftHit = bricks [ brickIndex ( x - radius , y + radius ) ] ;
const rightHit = bricks [ brickIndex ( x + radius , y + radius ) ] ;
if ( leftHit && ! rightHit ) {
ballOrCoin . vx += 1 ;
}
if ( ! leftHit && rightHit ) {
ballOrCoin . vx -= 1 ;
}
}
}
if ( typeof hhit !== "undefined" || typeof chit !== "undefined" ) {
if ( ! pierce ) {
ballOrCoin . x = ballOrCoin . previousx ;
ballOrCoin . vx *= - 1 ;
}
}
return vhit ? ? hhit ? ? chit ;
}
function bordersHitCheck ( coin , radius , delta ) {
if ( coin . destroyed ) return ;
coin . previousx = coin . x ;
coin . previousy = coin . y ;
coin . x += coin . vx * delta ;
coin . y += coin . vy * delta ;
coin . sx || = 0 ;
coin . sy || = 0 ;
coin . sx += coin . previousx - coin . x ;
coin . sy += coin . previousy - coin . y ;
coin . sx *= 0.9 ;
coin . sy *= 0.9 ;
2025-02-19 22:06:29 +01:00
if ( perks . wind ) {
coin . vx += ( puck - ( offsetX + gameZoneWidth / 2 ) ) / gameZoneWidth * perks . wind * 0.5 ;
}
2025-02-15 19:21:00 +01:00
let vhit = 0 , hhit = 0 ;
2025-02-18 16:03:31 +01:00
if ( coin . x < offsetXRoundedDown + radius ) {
coin . x = offsetXRoundedDown + radius ;
2025-02-15 19:21:00 +01:00
coin . vx *= - 1 ;
hhit = 1 ;
}
if ( coin . y < radius ) {
coin . y = radius ;
coin . vy *= - 1 ;
vhit = 1 ;
}
2025-02-18 16:03:31 +01:00
if ( coin . x > canvas . width - offsetXRoundedDown - radius ) {
coin . x = canvas . width - offsetXRoundedDown - radius ;
2025-02-15 19:21:00 +01:00
coin . vx *= - 1 ;
hhit = 1 ;
}
return hhit + vhit * 2 ;
}
let lastTickDown = 0 ;
2025-02-27 18:56:04 +01:00
2025-02-15 19:21:00 +01:00
function tick ( ) {
recomputeTargetBaseSpeed ( ) ;
const currentTick = performance . now ( ) ;
puckWidth = ( gameZoneWidth / 12 ) * ( 3 - perks . smaller _puck + perks . bigger _puck ) ;
2025-03-01 14:20:27 +01:00
if ( keyboardPuckSpeed ) {
setMousePos ( puck + keyboardPuckSpeed )
2025-02-27 18:56:04 +01:00
}
2025-03-01 14:20:27 +01:00
if ( running ) {
2025-02-15 19:21:00 +01:00
levelTime += currentTick - lastTick ;
2025-02-23 21:17:22 +01:00
runStatistics . runTime += currentTick - lastTick
runStatistics . max _combo = Math . max ( runStatistics . max _combo , combo )
2025-02-27 18:56:04 +01:00
// How many times to compute
2025-02-15 19:21:00 +01:00
let delta = Math . min ( 4 , ( currentTick - lastTick ) / ( 1000 / 60 ) ) ;
delta *= running ? 1 : 0
coins = coins . filter ( ( coin ) => ! coin . destroyed ) ;
balls = balls . filter ( ( ball ) => ! ball . destroyed ) ;
const remainingBricks = bricks . filter ( ( b ) => b && b !== "black" ) . length ;
if ( levelTime > lastTickDown + 1000 && perks . hot _start ) {
lastTickDown = levelTime ;
decreaseCombo ( perks . hot _start , puck , gameZoneHeight - 2 * puckHeight ) ;
}
2025-02-26 19:38:09 +01:00
if ( remainingBricks <= perks . skip _last ) {
2025-02-15 19:21:00 +01:00
bricks . forEach ( ( type , index ) => {
if ( type ) {
explodeBrick ( index , balls [ 0 ] , true ) ;
}
} ) ;
}
if ( ! remainingBricks && ! coins . length ) {
if ( currentLevel + 1 < max _levels ( ) ) {
setLevel ( currentLevel + 1 ) ;
} else {
gameOver ( "Run finished with " + score + " points" , "You cleared all levels for this run." ) ;
}
} else if ( running || levelTime ) {
let playedCoinBounce = false ;
const coinRadius = Math . round ( coinSize / 2 ) ;
2025-02-17 00:49:03 +01:00
2025-02-15 19:21:00 +01:00
coins . forEach ( ( coin ) => {
if ( coin . destroyed ) return ;
if ( perks . coin _magnet ) {
coin . vx += ( ( delta * ( puck - coin . x ) ) / ( 100 + Math . pow ( coin . y - gameZoneHeight , 2 ) + Math . pow ( coin . x - puck , 2 ) ) ) * perks . coin _magnet * 100 ;
}
const ratio = 1 - ( perks . viscosity * 0.03 + 0.005 ) * delta ;
coin . vy *= ratio ;
coin . vx *= ratio ;
2025-03-01 14:36:44 +01:00
if ( coin . vx > 7 * baseSpeed ) coin . vx = 7 * baseSpeed
if ( coin . vx < - 7 * baseSpeed ) coin . vx = - 7 * baseSpeed
if ( coin . vy > 7 * baseSpeed ) coin . vy = 7 * baseSpeed
if ( coin . vy < - 7 * baseSpeed ) coin . vy = - 7 * baseSpeed
2025-03-01 14:20:27 +01:00
coin . a += coin . sa
2025-02-15 19:21:00 +01:00
// Gravity
coin . vy += delta * coin . weight * 0.8 ;
const speed = Math . abs ( coin . sx ) + Math . abs ( coin . sx ) ;
const hitBorder = bordersHitCheck ( coin , coinRadius , delta ) ;
if ( coin . y > gameZoneHeight - coinRadius - puckHeight && coin . y < gameZoneHeight + puckHeight + coin . vy && Math . abs ( coin . x - puck ) < coinRadius + puckWidth / 2 + // a bit of margin to be nice
puckHeight ) {
addToScore ( coin ) ;
} else if ( coin . y > canvas . height + coinRadius ) {
coin . destroyed = true ;
2025-03-01 20:40:20 +01:00
if ( perks . compound _interest ) {
decreaseCombo ( coin . points * perks . compound _interest , coin . x , canvas . height - coinRadius ) ;
2025-02-15 19:21:00 +01:00
}
}
const hitBrick = brickHitCheck ( coin , coinRadius , false ) ;
if ( perks . metamorphosis && typeof hitBrick !== "undefined" ) {
if ( bricks [ hitBrick ] && coin . color !== bricks [ hitBrick ] && bricks [ hitBrick ] !== "black" && ! coin . coloredABrick ) {
bricks [ hitBrick ] = coin . color ;
coin . coloredABrick = true
}
}
if ( typeof hitBrick !== "undefined" || hitBorder ) {
coin . vx *= 0.8 ;
coin . vy *= 0.8 ;
2025-03-01 14:20:27 +01:00
coin . sa *= 0.9
2025-02-15 19:21:00 +01:00
if ( speed > 20 && ! playedCoinBounce ) {
playedCoinBounce = true ;
sounds . coinBounce ( coin . x , 0.2 ) ;
}
if ( Math . abs ( coin . vy ) < 3 ) {
coin . vy = 0 ;
}
}
} ) ;
balls . forEach ( ( ball ) => ballTick ( ball , delta ) ) ;
2025-02-19 22:06:29 +01:00
if ( perks . wind ) {
const windD = ( puck - ( offsetX + gameZoneWidth / 2 ) ) / gameZoneWidth * 2 * perks . wind
for ( var i = 0 ; i < perks . wind ; i ++ ) {
2025-02-20 11:34:11 +01:00
if ( Math . random ( ) * Math . abs ( windD ) > 0.5 ) {
2025-02-19 22:06:29 +01:00
flashes . push ( {
type : "particle" ,
duration : 150 ,
ethereal : true ,
time : levelTime ,
size : coinSize / 2 ,
color : rainbowColor ( ) ,
2025-02-20 11:34:11 +01:00
x : offsetXRoundedDown + Math . random ( ) * gameZoneWidthRoundedUp ,
2025-02-19 22:06:29 +01:00
y : Math . random ( ) * gameZoneHeight ,
2025-02-20 11:34:11 +01:00
vx : windD * 8 ,
2025-02-19 22:06:29 +01:00
vy : 0 ,
} ) ;
}
}
}
2025-02-15 19:21:00 +01:00
flashes . forEach ( ( flash ) => {
if ( flash . type === "particle" ) {
flash . x += flash . vx * delta ;
flash . y += flash . vy * delta ;
if ( ! flash . ethereal ) {
flash . vy += 0.5 ;
if ( hasBrick ( brickIndex ( flash . x , flash . y ) ) ) {
flash . destroyed = true ;
}
}
}
} ) ;
}
2025-03-01 14:20:27 +01:00
if ( combo > baseCombo ( ) ) {
// The red should still be visible on a white bg
const baseParticle = ! isSettingOn ( 'basic' ) && ( combo - baseCombo ( ) ) * Math . random ( ) > 5 && running && {
type : "particle" ,
duration : 100 * ( Math . random ( ) + 1 ) ,
time : levelTime ,
size : coinSize / 2 ,
color : 'red' ,
ethereal : true ,
}
if ( perks . top _is _lava ) {
baseParticle && flashes . push ( {
... baseParticle ,
x : offsetXRoundedDown + Math . random ( ) * gameZoneWidthRoundedUp ,
y : 0 ,
vx : ( Math . random ( ) - 0.5 ) * 10 ,
vy : 5 ,
} )
}
if ( perks . sides _are _lava ) {
const fromLeft = Math . random ( ) > 0.5
baseParticle && flashes . push ( {
... baseParticle ,
x : offsetXRoundedDown + ( fromLeft ? 0 : gameZoneWidthRoundedUp ) ,
y : Math . random ( ) * gameZoneHeight ,
vx : fromLeft ? 5 : - 5 ,
vy : ( Math . random ( ) - 0.5 ) * 10 ,
} )
}
2025-03-01 20:40:20 +01:00
if ( perks . compound _interest ) {
2025-03-01 14:20:27 +01:00
let x = puck
do {
x = offsetXRoundedDown + gameZoneWidthRoundedUp * Math . random ( )
} while ( Math . abs ( x - puck ) < puckWidth / 2 )
baseParticle && flashes . push ( {
... baseParticle ,
x ,
y : gameZoneHeight ,
vx : ( Math . random ( ) - 0.5 ) * 10 ,
vy : - 5 ,
} )
}
if ( perks . streak _shots ) {
const pos = ( 0.5 - Math . random ( ) )
baseParticle && flashes . push ( {
... baseParticle ,
duration : 100 ,
x : puck + puckWidth * pos ,
y : gameZoneHeight - puckHeight ,
vx : ( pos ) * 10 ,
vy : - 5 ,
} )
}
}
2025-02-15 19:21:00 +01:00
}
render ( ) ;
requestAnimationFrame ( tick ) ;
lastTick = currentTick ;
}
function isTelekinesisActive ( ball ) {
return perks . telekinesis && ! ball . hitSinceBounce && ball . vy < 0 ;
}
function ballTick ( ball , delta ) {
ball . previousvx = ball . vx ;
ball . previousvy = ball . vy ;
if ( isTelekinesisActive ( ball ) ) {
ball . vx += ( ( puck - ball . x ) / 1000 ) * delta * perks . telekinesis ;
2025-02-16 21:21:12 +01:00
}
2025-02-17 00:49:03 +01:00
const speedLimitDampener = 1 + perks . telekinesis + perks . ball _repulse _ball + perks . puck _repulse _ball + perks . ball _attract _ball
2025-02-16 21:21:12 +01:00
if ( ball . vx * ball . vx + ball . vy * ball . vy < baseSpeed * baseSpeed * 2 ) {
2025-02-17 00:49:03 +01:00
ball . vx *= ( 1 + . 02 / speedLimitDampener ) ;
ball . vy *= ( 1 + . 02 / speedLimitDampener ) ;
2025-02-15 19:21:00 +01:00
} else {
2025-02-17 00:49:03 +01:00
ball . vx *= ( 1 - . 02 / speedLimitDampener ) ;
2025-02-15 19:21:00 +01:00
if ( Math . abs ( ball . vy ) > 0.5 * baseSpeed ) {
2025-02-17 00:49:03 +01:00
ball . vy *= ( 1 - . 02 / speedLimitDampener ) ;
2025-02-15 19:21:00 +01:00
}
}
2025-02-17 00:49:03 +01:00
if ( perks . ball _repulse _ball ) {
for ( b2 of balls ) {
2025-02-16 21:21:12 +01:00
// avoid computing this twice, and repulsing itself
2025-02-17 00:49:03 +01:00
if ( b2 . x >= ball . x ) continue
repulse ( ball , b2 , perks . ball _repulse _ball , true )
2025-02-16 21:21:12 +01:00
}
}
2025-02-17 00:49:03 +01:00
if ( perks . ball _attract _ball ) {
for ( b2 of balls ) {
// avoid computing this twice, and repulsing itself
if ( b2 . x >= ball . x ) continue
2025-02-19 21:11:22 +01:00
attract ( ball , b2 , perks . ball _attract _ball )
2025-02-17 00:49:03 +01:00
}
}
2025-02-19 22:06:29 +01:00
if ( perks . puck _repulse _ball && Math . abs ( ball . x - puck ) < puckWidth / 2 + ballSize * ( 9 + perks . puck _repulse _ball ) / 10 ) {
2025-02-17 00:49:03 +01:00
repulse ( ball , {
x : puck ,
2025-03-01 20:40:20 +01:00
y : gameZoneHeight
2025-02-17 00:49:03 +01:00
} , perks . puck _repulse _ball , false )
2025-02-16 21:21:12 +01:00
}
2025-03-01 14:20:27 +01:00
if ( perks . respawn && ball . hitItem ? . length > 1 && ! isSettingOn ( 'basic' ) ) {
for ( let i = 0 ; i < ball . hitItem ? . length - 1 && i < perks . respawn ; i ++ ) {
const { index , color } = ball . hitItem [ i ]
if ( bricks [ index ] || color === 'black' ) continue
const vertical = Math . random ( ) > 0.5
const dx = Math . random ( ) > 0.5 ? 1 : - 1
const dy = Math . random ( ) > 0.5 ? 1 : - 1
flashes . push ( {
type : "particle" ,
duration : 250 ,
ethereal : true ,
time : levelTime ,
size : coinSize / 2 ,
color ,
x : brickCenterX ( index ) + dx * brickWidth / 2 ,
y : brickCenterY ( index ) + dy * brickWidth / 2 ,
vx : vertical ? 0 : - dx * baseSpeed ,
vy : vertical ? - dy * baseSpeed : 0 ,
} ) ;
}
}
2025-02-16 21:21:12 +01:00
2025-02-15 19:21:00 +01:00
const borderHitCode = bordersHitCheck ( ball , ballSize / 2 , delta ) ;
if ( borderHitCode ) {
if ( perks . sides _are _lava && borderHitCode % 2 ) {
resetCombo ( ball . x , ball . y ) ;
}
if ( perks . top _is _lava && borderHitCode >= 2 ) {
resetCombo ( ball . x , ball . y + ballSize ) ;
}
sounds . wallBeep ( ball . x ) ;
ball . bouncesList ? . push ( { x : ball . previousx , y : ball . previousy } )
}
// Puck collision
const ylimit = gameZoneHeight - puckHeight - ballSize / 2 ;
if ( ball . y > ylimit && Math . abs ( ball . x - puck ) < ballSize / 2 + puckWidth / 2 && ball . vy > 0 ) {
const speed = Math . sqrt ( ball . vx * ball . vx + ball . vy * ball . vy ) ;
const angle = Math . atan2 ( - puckWidth / 2 , ball . x - puck ) ;
ball . vx = speed * Math . cos ( angle ) ;
ball . vy = speed * Math . sin ( angle ) ;
sounds . wallBeep ( ball . x ) ;
if ( perks . streak _shots ) {
resetCombo ( ball . x , ball . y ) ;
}
2025-02-21 12:35:42 +01:00
2025-02-23 21:17:22 +01:00
if ( perks . respawn ) {
ball . hitItem . slice ( 0 , - 1 ) . slice ( 0 , perks . respawn )
2025-03-01 14:20:27 +01:00
. forEach ( ( { index , color } ) => {
if ( ! bricks [ index ] && color !== 'black' )
bricks [ index ] = color
} )
2025-02-21 12:35:42 +01:00
}
2025-02-23 21:17:22 +01:00
ball . hitItem = [ ]
2025-02-15 19:21:00 +01:00
if ( ! ball . hitSinceBounce ) {
2025-02-23 21:17:22 +01:00
runStatistics . misses ++
2025-02-15 19:21:00 +01:00
levelMisses ++ ;
2025-03-01 14:36:44 +01:00
resetCombo ( ball . x , ball . y )
flashes . push ( {
type : "text" ,
text : 'miss' ,
duration : 500 ,
time : levelTime ,
size : puckHeight * 1.5 ,
color : 'red' ,
x : puck ,
y : gameZoneHeight - puckHeight * 2 ,
} ) ;
2025-02-15 19:21:00 +01:00
}
2025-02-23 21:17:22 +01:00
runStatistics . puck _bounces ++
2025-02-15 19:21:00 +01:00
ball . hitSinceBounce = 0 ;
2025-02-23 21:17:22 +01:00
ball . sapperUses = 0 ;
2025-02-15 19:21:00 +01:00
ball . piercedSinceBounce = 0 ;
ball . bouncesList = [ {
2025-02-17 00:49:03 +01:00
x : ball . previousx ,
y : ball . previousy
2025-02-15 19:21:00 +01:00
} ]
}
if ( ball . y > gameZoneHeight + ballSize / 2 && running ) {
ball . destroyed = true ;
2025-02-23 21:17:22 +01:00
runStatistics . balls _lost ++
2025-02-15 19:21:00 +01:00
if ( ! balls . find ( ( b ) => ! b . destroyed ) ) {
if ( perks . extra _life ) {
perks . extra _life -- ;
resetBalls ( ) ;
sounds . revive ( ) ;
2025-02-27 18:56:04 +01:00
pause ( false )
2025-02-15 19:21:00 +01:00
coins = [ ] ;
flashes . push ( {
type : "ball" ,
duration : 500 ,
time : levelTime ,
size : brickWidth * 2 ,
color : "white" ,
x : ball . x ,
y : ball . y ,
} ) ;
} else {
gameOver ( "Game Over" , "You dropped the ball after catching " + score + " coins. " ) ;
}
}
}
const hitBrick = brickHitCheck ( ball , ballSize / 2 , true ) ;
if ( typeof hitBrick !== "undefined" ) {
2025-02-21 12:35:42 +01:00
const initialBrickColor = bricks [ hitBrick ]
2025-02-15 19:21:00 +01:00
explodeBrick ( hitBrick , ball , false ) ;
2025-02-23 21:17:22 +01:00
if ( ball . sapperUses < perks . sapper && initialBrickColor !== "black" &&
2025-02-21 12:35:42 +01:00
// don't replace a brick that bounced with sturdy_bricks
! bricks [ hitBrick ] ) {
2025-02-15 19:21:00 +01:00
bricks [ hitBrick ] = "black" ;
2025-02-23 21:17:22 +01:00
ball . sapperUses ++
2025-02-15 19:21:00 +01:00
}
2025-02-21 12:35:42 +01:00
2025-02-15 19:21:00 +01:00
}
if ( ! isSettingOn ( "basic" ) ) {
ball . sparks += ( delta * ( combo - 1 ) ) / 30 ;
if ( ball . sparks > 1 ) {
flashes . push ( {
type : "particle" ,
duration : 100 * ball . sparks ,
time : levelTime ,
size : coinSize / 2 ,
2025-03-01 20:40:20 +01:00
color : ballsColor ,
2025-02-15 19:21:00 +01:00
x : ball . x ,
y : ball . y ,
vx : ( Math . random ( ) - 0.5 ) * baseSpeed ,
vy : ( Math . random ( ) - 0.5 ) * baseSpeed ,
} ) ;
ball . sparks = 0 ;
}
}
}
let runStatistics = { } ;
function getTotalScore ( ) {
try {
return JSON . parse ( localStorage . getItem ( 'breakout_71_total_score' ) || '0' )
} catch ( e ) {
return 0
}
}
function addToTotalScore ( points ) {
2025-02-17 00:49:03 +01:00
if ( hadOverrides ) return
2025-02-15 19:21:00 +01:00
try {
localStorage . setItem ( 'breakout_71_total_score' , JSON . stringify ( getTotalScore ( ) + points ) )
} catch ( e ) {
}
}
2025-02-27 23:04:42 +01:00
function addToTotalPlayTime ( ms ) {
try {
localStorage . setItem ( 'breakout_71_total_play_time' , JSON . stringify ( JSON . parse ( localStorage . getItem ( 'breakout_71_total_play_time' ) || '0' ) + ms ) )
} catch ( e ) {
}
}
2025-02-23 21:17:22 +01:00
2025-02-15 19:21:00 +01:00
function gameOver ( title , intro ) {
if ( ! running ) return ;
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-20 11:34:11 +01:00
stopRecording ( )
2025-03-01 14:20:27 +01:00
addToTotalPlayTime ( runStatistics . runTime )
runStatistics . max _level = currentLevel + 1
2025-02-15 19:21:00 +01:00
let animationDelay = - 300
const getDelay = ( ) => {
animationDelay += 800
return 'animation-delay:' + animationDelay + 'ms;'
}
// unlocks
let unlocksInfo = ''
const endTs = getTotalScore ( )
const startTs = endTs - score
const list = getUpgraderUnlockPoints ( )
list . filter ( u => u . threshold > startTs && u . threshold < endTs ) . forEach ( u => {
unlocksInfo += `
2025-02-19 12:37:21 +01:00
< p class = "progress" >
2025-02-15 19:21:00 +01:00
< span > $ { u . title } < / s p a n >
< span class = "progress_bar_part" style = "${getDelay()}" > < / s p a n >
< / p >
`
} )
2025-02-26 20:44:47 +01:00
const previousUnlockAt = findLast ( list , u => u . threshold <= endTs ) ? . threshold || 0
2025-02-15 19:21:00 +01:00
const nextUnlock = list . find ( u => u . threshold > endTs )
if ( nextUnlock ) {
const total = nextUnlock ? . threshold - previousUnlockAt
const done = endTs - previousUnlockAt
intro += ` Score ${ nextUnlock . threshold - endTs } more points to reach the next unlock. `
2025-02-17 01:07:20 +01:00
const scaleX = ( done / total ) . toFixed ( 2 )
2025-02-15 19:21:00 +01:00
unlocksInfo += `
2025-02-19 12:37:21 +01:00
< p class = "progress" >
2025-02-15 19:21:00 +01:00
< span > $ { nextUnlock . title } < / s p a n >
2025-02-17 00:49:03 +01:00
< span style = "transform: scale(${scaleX},1);${getDelay()}" class = "progress_bar_part" > < / s p a n >
2025-02-15 19:21:00 +01:00
< / p >
`
list . slice ( list . indexOf ( nextUnlock ) + 1 ) . slice ( 0 , 3 ) . forEach ( u => {
unlocksInfo += `
2025-02-19 12:37:21 +01:00
< p class = "progress" >
2025-02-15 19:21:00 +01:00
< span > $ { u . title } < / s p a n >
< / p >
`
} )
}
2025-02-23 21:17:22 +01:00
2025-02-15 22:19:13 +01:00
// Avoid the sad sound right as we restart a new games
2025-02-17 00:49:03 +01:00
combo = 1
2025-02-15 19:21:00 +01:00
asyncAlert ( {
allowClose : true , title , text : `
< p > $ { intro } < / p >
$ { unlocksInfo }
` , textAfterButtons: `
2025-02-20 11:34:11 +01:00
< div id = "level-recording-container" > < / d i v >
2025-02-24 21:04:31 +01:00
$ { getHistograms ( true ) }
2025-02-15 19:21:00 +01:00
`
} ) . then ( ( ) => restart ( ) ) ;
}
2025-03-01 14:20:27 +01:00
function getHistograms ( saveStats ) {
2025-02-24 21:04:31 +01:00
2025-03-01 14:20:27 +01:00
if ( hadOverrides ) {
return ''
}
2025-02-24 21:04:31 +01:00
2025-03-01 14:20:27 +01:00
let runStats = ''
2025-02-24 21:04:31 +01:00
try {
// Stores only top 100 runs
let runsHistory = JSON . parse ( localStorage . getItem ( 'breakout_71_runs_history' ) || '[]' ) ;
2025-03-01 14:20:27 +01:00
runsHistory . sort ( ( a , b ) => a . score - b . score ) . reverse ( )
runsHistory = runsHistory . slice ( 0 , 10 )
runsHistory . push ( runStatistics )
2025-02-24 21:04:31 +01:00
// Generate some histogram
2025-03-01 14:20:27 +01:00
if ( saveStats ) {
2025-02-24 21:04:31 +01:00
localStorage . setItem ( 'breakout_71_runs_history' , JSON . stringify ( runsHistory , null , 2 ) )
}
const makeHistogram = ( title , getter , unit ) => {
let values = runsHistory . map ( h => getter ( h ) || 0 )
2025-02-25 21:23:37 +01:00
let min = Math . min ( ... values )
let max = Math . max ( ... values )
2025-02-24 21:04:31 +01:00
// No point
2025-03-01 14:20:27 +01:00
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 )
2025-02-25 21:23:37 +01:00
}
2025-02-24 21:04:31 +01:00
// One bin per unique value, max 10
2025-03-01 14:20:27 +01:00
const binsCount = Math . min ( values . length , 10 )
if ( binsCount < 3 ) return ''
2025-02-24 21:04:31 +01:00
const bins = [ ]
const binsTotal = [ ]
2025-03-01 14:20:27 +01:00
for ( let i = 0 ; i < binsCount ; i ++ ) {
2025-02-24 21:04:31 +01:00
bins . push ( 0 )
binsTotal . push ( 0 )
}
const binSize = ( max - min ) / bins . length
const binIndexOf = v => Math . min ( bins . length - 1 , Math . floor ( ( v - min ) / binSize ) )
values . forEach ( v => {
2025-03-01 14:20:27 +01:00
if ( isNaN ( v ) ) return
const index = binIndexOf ( v )
2025-02-24 21:04:31 +01:00
bins [ index ] ++
2025-03-01 14:20:27 +01:00
binsTotal [ index ] += v
2025-02-24 21:04:31 +01:00
} )
2025-03-01 14:20:27 +01:00
if ( bins . filter ( b => b ) . length < 3 ) return ''
2025-02-24 21:04:31 +01:00
const maxBin = Math . max ( ... bins )
const lastValue = values [ values . length - 1 ]
const activeBin = binIndexOf ( lastValue )
return ` <h2 class="histogram-title"> ${ title } : <strong> ${ lastValue } ${ unit } </strong></h2><div class="histogram">
2025-03-01 14:20:27 +01:00
$ { bins . map ( ( v , vi ) => ` <span class=" ${ vi === activeBin ? 'active' : '' } "><span style="height: ${ v / maxBin * 80 } px" title=" ${ v } run ${ v > 1 ? 's' : '' } between ${
2025-02-24 21:04:31 +01:00
Math . floor ( min + vi * binSize ) } and $ { Math . floor ( min + ( vi + 1 ) * binSize ) } $ { unit } "
> < span > $ {
2025-03-01 14:20:27 +01:00
( ! v && ' ' ) || ( vi == activeBin && lastValue + unit ) || ( Math . round ( binsTotal [ vi ] / v ) + unit )
2025-02-24 21:04:31 +01:00
} < / s p a n > < / s p a n > < / s p a n > ` ) . j o i n ( ' ' ) }
< / d i v >
`
}
runStats += makeHistogram ( 'Total score' , r => r . score , '' )
runStats += makeHistogram ( 'Catch rate' , r => Math . round ( r . score / r . coins _spawned * 100 ) , '%' )
runStats += makeHistogram ( 'Bricks broken' , r => r . bricks _broken , '' )
2025-03-01 14:20:27 +01:00
runStats += makeHistogram ( 'Bricks broken per minute' , r => Math . round ( r . bricks _broken / r . runTime * 1000 * 60 ) , ' bpm' )
runStats += makeHistogram ( 'Hit rate' , r => Math . round ( ( 1 - r . misses / r . puck _bounces ) * 100 ) , '%' )
runStats += makeHistogram ( 'Duration per level' , r => Math . round ( r . runTime / 1000 / r . levelsPlayed ) , 's' )
2025-02-24 21:04:31 +01:00
runStats += makeHistogram ( 'Level reached' , r => r . levelsPlayed , '' )
runStats += makeHistogram ( 'Upgrades applied' , r => r . upgrades _picked , '' )
runStats += makeHistogram ( 'Balls lost' , r => r . balls _lost , '' )
2025-03-01 14:20:27 +01:00
runStats += makeHistogram ( 'Average combo' , r => Math . round ( r . coins _spawned / r . bricks _broken ) , '' )
runStats += makeHistogram ( 'Max combo' , r => r . max _combo , '' )
2025-02-24 21:04:31 +01:00
2025-03-01 14:20:27 +01:00
if ( runStats ) {
runStats = ` <p>Find below your run statistics compared to your ${ runsHistory . length - 1 } best runs.</p> ` + runStats
2025-02-24 21:04:31 +01:00
}
} catch ( e ) {
console . warn ( e )
}
return runStats
}
2025-02-23 21:17:22 +01:00
function resetRunStatistics ( ) {
runStatistics = {
started : Date . now ( ) ,
levelsPlayed : 0 ,
runTime : 0 ,
coins _spawned : 0 ,
score : 0 ,
2025-03-01 14:20:27 +01:00
bricks _broken : 0 ,
misses : 0 ,
balls _lost : 0 ,
puck _bounces : 0 ,
upgrades _picked : 1 ,
max _combo : 1
2025-02-23 21:17:22 +01:00
}
}
2025-03-01 14:20:27 +01:00
2025-02-15 19:21:00 +01:00
function explodeBrick ( index , ball , isExplosion ) {
const color = bricks [ index ] ;
2025-03-01 14:20:27 +01:00
if ( ! color ) return ;
2025-02-23 21:17:22 +01:00
2025-02-15 19:21:00 +01:00
if ( color === 'black' ) {
delete bricks [ index ] ;
const x = brickCenterX ( index ) , y = brickCenterY ( index ) ;
sounds . explode ( ball . x ) ;
2025-03-01 20:40:20 +01:00
const col = index % gridSize
const row = Math . floor ( index / gridSize )
2025-02-15 19:21:00 +01:00
const size = 1 + perks . bigger _explosions ;
// Break bricks around
for ( let dx = - size ; dx <= size ; dx ++ ) {
for ( let dy = - size ; dy <= size ; dy ++ ) {
const i = getRowColIndex ( row + dy , col + dx ) ;
if ( bricks [ i ] && i !== - 1 ) {
explodeBrick ( i , ball , true )
}
}
}
// Blow nearby coins
coins . forEach ( ( c ) => {
const dx = c . x - x ;
const dy = c . y - y ;
const d2 = Math . max ( brickWidth , Math . abs ( dx ) + Math . abs ( dy ) ) ;
c . vx += ( dx / d2 ) * 10 * size / c . weight ;
c . vy += ( dy / d2 ) * 10 * size / c . weight ;
} ) ;
lastexplosion = Date . now ( ) ;
flashes . push ( {
type : "ball" , duration : 150 , time : levelTime , size : brickWidth * 2 , color : "white" , x , y ,
} ) ;
2025-02-19 12:37:21 +01:00
spawnExplosion ( 7 * ( 1 + perks . bigger _explosions ) , x , y , 'white' , 150 , coinSize , ) ;
2025-02-15 19:21:00 +01:00
ball . hitSinceBounce ++ ;
2025-02-23 21:17:22 +01:00
runStatistics . bricks _broken ++
2025-02-15 19:21:00 +01:00
} else if ( color ) {
2025-02-21 12:35:42 +01:00
// Even if it bounces we don't want to count that as a miss
ball . hitSinceBounce ++ ;
2025-03-01 14:20:27 +01:00
if ( perks . sturdy _bricks && perks . sturdy _bricks > Math . random ( ) * 5 ) {
2025-02-21 12:35:42 +01:00
// Resist
sounds . coinBounce ( ball . x , 1 )
return
}
2025-02-15 19:21:00 +01:00
// Flashing is take care of by the tick loop
const x = brickCenterX ( index ) , y = brickCenterY ( index ) ;
bricks [ index ] = "" ;
coins = coins . filter ( ( c ) => ! c . destroyed ) ;
2025-02-23 21:17:22 +01:00
let coinsToSpawn = combo
if ( perks . sturdy _bricks ) {
2025-02-21 12:35:42 +01:00
// +10% per level
2025-02-23 21:17:22 +01:00
coinsToSpawn += Math . ceil ( ( 10 + perks . sturdy _bricks ) / 10 * coinsToSpawn )
2025-02-21 12:35:42 +01:00
}
2025-03-01 14:20:27 +01:00
levelSpawnedCoins += coinsToSpawn ;
runStatistics . coins _spawned += coinsToSpawn
runStatistics . bricks _broken ++
const maxCoins = MAX _COINS * ( isSettingOn ( "basic" ) ? 0.5 : 1 )
2025-03-01 20:40:20 +01:00
const spawnableCoins = Math . floor ( maxCoins - coins . length ) / 3
const pointsPerCoin = Math . max ( 1 , Math . ceil ( coinsToSpawn / spawnableCoins ) )
2025-03-01 14:20:27 +01:00
while ( coinsToSpawn > 0 ) {
const points = Math . min ( pointsPerCoin , coinsToSpawn )
coinsToSpawn -= points
2025-02-15 19:21:00 +01:00
const coord = {
x : x + ( Math . random ( ) - 0.5 ) * ( brickWidth - coinSize ) ,
y : y + ( Math . random ( ) - 0.5 ) * ( brickWidth - coinSize ) ,
} ;
coins . push ( {
2025-03-01 14:20:27 +01:00
points ,
color : perks . metamorphosis ? color : 'gold' ,
2025-02-26 23:03:12 +01:00
... coord ,
2025-02-15 19:21:00 +01:00
previousx : coord . x ,
previousy : coord . y ,
2025-02-17 17:52:20 +01:00
// Use previous speed because the ball has already bounced
vx : ball . previousvx * ( 0.5 + Math . random ( ) ) ,
vy : ball . previousvy * ( 0.5 + Math . random ( ) ) ,
2025-02-15 19:21:00 +01:00
sx : 0 ,
sy : 0 ,
2025-03-01 14:20:27 +01:00
a : Math . random ( ) * Math . PI * 2 ,
sa : Math . random ( ) - 0.5 ,
2025-02-15 19:21:00 +01:00
weight : 0.8 + Math . random ( ) * 0.2
} ) ;
}
2025-02-19 12:37:21 +01:00
2025-03-01 20:40:20 +01:00
combo += Math . max ( 0 , perks . streak _shots + perks . compound _interest + perks . sides _are _lava + perks . top _is _lava + perks . picky _eater
2025-02-19 21:11:22 +01:00
- Math . round ( Math . random ( ) * perks . soft _reset ) ) ;
2025-02-15 19:21:00 +01:00
if ( ! isExplosion ) {
// color change
2025-03-01 20:40:20 +01:00
if ( ( perks . picky _eater || perks . pierce _color ) && color !== ballsColor && color ) {
if ( perks . picky _eater ) {
2025-03-01 14:20:27 +01:00
resetCombo ( ball . x , ball . y ) ;
}
2025-03-01 20:40:20 +01:00
ballsColor = color ;
2025-02-15 19:21:00 +01:00
} else {
sounds . comboIncreaseMaybe ( ball . x , 1 ) ;
}
}
flashes . push ( {
type : "ball" , duration : 40 , time : levelTime , size : brickWidth , color : color , x , y ,
} ) ;
2025-02-19 21:11:22 +01:00
spawnExplosion ( 5 + combo , x , y , color , 100 , coinSize / 2 ) ;
2025-02-15 19:21:00 +01:00
}
2025-02-21 12:35:42 +01:00
2025-03-01 14:20:27 +01:00
if ( ! bricks [ index ] ) {
2025-02-21 12:35:42 +01:00
ball . hitItem ? . push ( {
index ,
color
} )
}
2025-02-15 19:21:00 +01:00
}
2025-03-01 14:20:27 +01:00
2025-02-15 19:21:00 +01:00
function max _levels ( ) {
2025-02-17 00:49:03 +01:00
if ( hadOverrides ) return 1
2025-02-15 19:21:00 +01:00
return 7 + perks . extra _levels ;
}
function render ( ) {
if ( running ) needsRender = true
if ( ! needsRender ) {
return
}
needsRender = false ;
const level = currentLevelInfo ( ) ;
const { width , height } = canvas ;
if ( ! width || ! height ) return ;
let scoreInfo = "" ;
for ( let i = 0 ; i < perks . extra _life ; i ++ ) {
scoreInfo += "🖤 " ;
}
2025-03-01 20:40:20 +01:00
scoreInfo += 'L' + ( currentLevel + 1 ) + '/' + max _levels ( ) + ' ' ;
scoreInfo += '$' + score . toString ( ) ;
2025-03-01 14:36:44 +01:00
2025-02-15 19:21:00 +01:00
scoreDisplay . innerText = scoreInfo ;
2025-02-27 22:19:50 +01:00
// Clear
2025-02-15 19:21:00 +01:00
if ( ! isSettingOn ( "basic" ) && ! level . color && level . svg && ! level . black _puck ) {
2025-02-18 16:03:31 +01:00
// Without this the light trails everything
2025-02-15 19:21:00 +01:00
ctx . globalCompositeOperation = "source-over" ;
2025-02-18 16:03:31 +01:00
ctx . globalAlpha = . 4
2025-02-15 19:21:00 +01:00
ctx . fillStyle = "#000" ;
ctx . fillRect ( 0 , 0 , width , height ) ;
2025-02-18 16:03:31 +01:00
2025-02-15 19:21:00 +01:00
ctx . globalCompositeOperation = "screen" ;
ctx . globalAlpha = 0.6 ;
coins . forEach ( ( coin ) => {
if ( ! coin . destroyed ) drawFuzzyBall ( ctx , coin . color , coinSize * 2 , coin . x , coin . y ) ;
} ) ;
balls . forEach ( ( ball ) => {
2025-03-01 20:40:20 +01:00
drawFuzzyBall ( ctx , ballsColor , ballSize * 2 , ball . x , ball . y ) ;
2025-02-15 19:21:00 +01:00
} ) ;
ctx . globalAlpha = 0.5 ;
bricks . forEach ( ( color , index ) => {
if ( ! color ) return ;
const x = brickCenterX ( index ) , y = brickCenterY ( index ) ;
drawFuzzyBall ( ctx , color == 'black' ? '#666' : color , brickWidth , x , y ) ;
} ) ;
ctx . globalAlpha = 1 ;
flashes . forEach ( ( flash ) => {
const { x , y , time , color , size , type , duration } = flash ;
const elapsed = levelTime - time ;
ctx . globalAlpha = Math . min ( 1 , 2 - ( elapsed / duration ) * 2 ) ;
2025-02-17 00:49:03 +01:00
if ( type === "ball" ) {
2025-02-15 19:21:00 +01:00
drawFuzzyBall ( ctx , color , size , x , y ) ;
}
2025-02-17 00:49:03 +01:00
if ( type === "particle" ) {
drawFuzzyBall ( ctx , color , size * 3 , x , y ) ;
}
2025-02-15 19:21:00 +01:00
} ) ;
2025-02-18 23:57:40 +01:00
// Decides how brights the bg black parts can get
2025-02-18 16:03:31 +01:00
ctx . globalAlpha = . 2 ;
ctx . globalCompositeOperation = "multiply" ;
2025-02-18 23:57:40 +01:00
ctx . fillStyle = "black" ;
ctx . fillRect ( 0 , 0 , width , height ) ;
2025-02-18 16:03:31 +01:00
// Decides how dark the background black parts are when lit (1=black)
ctx . globalAlpha = . 8 ;
2025-02-15 19:21:00 +01:00
ctx . globalCompositeOperation = "multiply" ;
2025-02-25 21:23:37 +01:00
if ( level . svg && background . width && background . complete ) {
2025-02-25 14:36:07 +01:00
2025-03-01 14:20:27 +01:00
if ( backgroundCanvas . title !== level . name ) {
2025-02-15 19:21:00 +01:00
backgroundCanvas . title = level . name
backgroundCanvas . width = canvas . width
backgroundCanvas . height = canvas . height
const bgctx = backgroundCanvas . getContext ( "2d" )
2025-02-25 21:23:37 +01:00
bgctx . fillStyle = level . color || '#000'
2025-02-15 19:21:00 +01:00
bgctx . fillRect ( 0 , 0 , canvas . width , canvas . height )
2025-02-25 21:23:37 +01:00
bgctx . fillStyle = ctx . createPattern ( background , "repeat" ) ;
bgctx . fillRect ( 0 , 0 , width , height ) ;
2025-02-18 16:03:31 +01:00
}
2025-02-25 21:23:37 +01:00
ctx . drawImage ( backgroundCanvas , 0 , 0 )
2025-03-01 14:20:27 +01:00
} else {
2025-02-25 21:23:37 +01:00
// Background not loaded yes
ctx . fillStyle = "#000" ;
ctx . fillRect ( 0 , 0 , width , height ) ;
2025-02-15 19:21:00 +01:00
}
} else {
ctx . globalAlpha = 1
2025-02-18 16:03:31 +01:00
ctx . globalCompositeOperation = "source-over" ;
2025-02-15 19:21:00 +01:00
ctx . fillStyle = level . color || "#000" ;
ctx . fillRect ( 0 , 0 , width , height ) ;
flashes . forEach ( ( flash ) => {
const { x , y , time , color , size , type , duration } = flash ;
const elapsed = 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 ( ) - lastexplosion + 5 ;
const shaked = lastExplosionDelay < 200 ;
if ( shaked ) {
2025-02-18 23:57:40 +01:00
const amplitude = ( perks . bigger _explosions + 1 ) * 50 / lastExplosionDelay
ctx . translate ( Math . sin ( Date . now ( ) ) * amplitude , Math . sin ( Date . now ( ) + 36 ) * amplitude ) ;
2025-02-15 19:21:00 +01:00
}
ctx . globalCompositeOperation = "source-over" ;
renderAllBricks ( ctx ) ;
ctx . globalCompositeOperation = "screen" ;
flashes = flashes . filter ( ( f ) => levelTime - f . time < f . duration && ! f . destroyed , ) ;
flashes . forEach ( ( flash ) => {
const { x , y , time , color , size , type , text , duration , points } = flash ;
const elapsed = levelTime - time ;
ctx . globalAlpha = Math . max ( 0 , Math . min ( 1 , 2 - ( elapsed / duration ) * 2 ) ) ;
if ( type === "text" ) {
ctx . globalCompositeOperation = "source-over" ;
2025-03-01 14:20:27 +01:00
drawText ( ctx , text , color , size , x , y - elapsed / 10 ) ;
2025-02-15 19:21:00 +01:00
} else if ( type === "particle" ) {
ctx . globalCompositeOperation = "screen" ;
drawBall ( ctx , color , size , x , y ) ;
drawFuzzyBall ( ctx , color , size , x , y ) ;
}
} ) ;
// Coins
ctx . globalAlpha = 1 ;
ctx . globalCompositeOperation = "source-over" ;
coins . forEach ( ( coin ) => {
2025-03-01 14:20:27 +01:00
if ( ! coin . destroyed ) drawCoin ( ctx , coin . color , coinSize , coin . x , coin . y , level . color || 'black' , coin . a ) ;
2025-02-15 19:21:00 +01:00
} ) ;
// Black shadow around balls
if ( coins . length > 10 && ! isSettingOn ( 'basic' ) ) {
ctx . globalAlpha = Math . min ( 0.8 , ( coins . length - 10 ) / 50 ) ;
balls . forEach ( ( ball ) => {
drawBall ( ctx , level . color || "#000" , ballSize * 6 , ball . x , ball . y ) ;
} ) ;
}
ctx . globalAlpha = 1
ctx . globalCompositeOperation = "source-over" ;
const puckColor = level . black _puck ? '#000' : '#FFF'
balls . forEach ( ( ball ) => {
2025-03-01 20:40:20 +01:00
drawBall ( ctx , ballsColor , ballSize , ball . x , ball . y , puckColor ) ;
2025-02-15 19:21:00 +01:00
// effect
if ( isTelekinesisActive ( ball ) ) {
ctx . strokeStyle = puckColor ;
ctx . beginPath ( ) ;
ctx . bezierCurveTo ( puck , gameZoneHeight , puck , ball . y , ball . x , ball . y ) ;
ctx . stroke ( ) ;
}
} ) ;
// The puck
ctx . globalAlpha = 1
ctx . globalCompositeOperation = "source-over" ;
2025-03-01 14:20:27 +01:00
if ( perks . streak _shots && combo > baseCombo ( ) ) {
drawPuck ( ctx , 'red' , puckWidth , puckHeight , - 2 )
}
2025-02-15 19:21:00 +01:00
drawPuck ( ctx , puckColor , puckWidth , puckHeight )
if ( combo > 1 ) {
2025-02-17 00:49:03 +01:00
ctx . globalCompositeOperation = "source-over" ;
2025-03-01 14:20:27 +01:00
const comboText = "x " + combo
const comboTextWidth = comboText . length * puckHeight / 2.6
drawCoin ( ctx , 'gold' , coinSize , puck - coinSize * 1.5 - comboTextWidth / 2 , gameZoneHeight - puckHeight / 2 , ! level . black _puck ? '#FFF' : '#000' , 0 )
2025-02-27 22:19:50 +01:00
drawText ( ctx , comboText , ! level . black _puck ? '#000' : '#FFF' , puckHeight ,
2025-03-01 14:20:27 +01:00
puck - comboTextWidth / 2 , gameZoneHeight - puckHeight / 2 , true ) ;
2025-02-15 19:21:00 +01:00
}
// Borders
2025-03-01 14:20:27 +01:00
const redSides = perks . sides _are _lava && combo > baseCombo ( )
ctx . fillStyle = redSides ? 'red' : puckColor ;
2025-02-15 19:21:00 +01:00
ctx . globalCompositeOperation = "source-over" ;
2025-02-18 16:03:31 +01:00
if ( offsetXRoundedDown ) {
2025-02-20 11:34:11 +01:00
// draw outside of gaming area to avoid capturing borders in recordings
ctx . fillRect ( offsetX - 1 , 0 , 1 , height ) ;
ctx . fillRect ( width - offsetX + 1 , 0 , 1 , height ) ;
2025-03-01 14:20:27 +01:00
} else if ( redSides ) {
ctx . fillRect ( 0 , 0 , 1 , height ) ;
ctx . fillRect ( width - 1 , 0 , 1 , height ) ;
2025-02-15 19:21:00 +01:00
}
2025-03-01 14:20:27 +01:00
if ( perks . top _is _lava && combo > baseCombo ( ) )
drawRedSquare ( ctx , offsetXRoundedDown , 0 , gameZoneWidthRoundedUp , 1 ) ;
2025-03-01 20:40:20 +01:00
const redBottom = perks . compound _interest && combo > baseCombo ( )
2025-03-01 14:20:27 +01:00
ctx . fillStyle = redBottom ? 'red' : puckColor ;
2025-02-15 19:21:00 +01:00
if ( isSettingOn ( "mobile-mode" ) ) {
2025-02-18 16:03:31 +01:00
ctx . fillRect ( offsetXRoundedDown , gameZoneHeight , gameZoneWidthRoundedUp , 1 ) ;
2025-02-15 19:21:00 +01:00
if ( ! running ) {
2025-02-26 23:03:12 +01:00
drawText ( ctx , "Press and hold here to play" , puckColor , puckHeight ,
2025-03-01 14:20:27 +01:00
canvas . width / 2 , gameZoneHeight + ( canvas . height - gameZoneHeight ) / 2 ,
2025-02-26 23:03:12 +01:00
) ;
2025-02-15 19:21:00 +01:00
}
2025-03-01 14:20:27 +01:00
} else if ( redBottom ) {
ctx . fillRect ( offsetXRoundedDown , gameZoneHeight - 1 , gameZoneWidthRoundedUp , 1 ) ;
2025-02-15 19:21:00 +01:00
}
if ( shaked ) {
ctx . resetTransform ( ) ;
}
2025-02-20 11:34:11 +01:00
recordOneFrame ( )
2025-02-15 19:21:00 +01:00
}
let cachedBricksRender = document . createElement ( "canvas" ) ;
let cachedBricksRenderKey = null ;
function renderAllBricks ( destinationCtx ) {
ctx . globalAlpha = 1 ;
const level = currentLevelInfo ( ) ;
2025-03-01 14:20:27 +01:00
const redBorderOnBricksWithWrongColor = combo > baseCombo ( ) && perks . picky _eater
2025-02-15 19:21:00 +01:00
2025-03-01 20:40:20 +01:00
const newKey = gameZoneWidth + "_" + bricks . join ( "_" ) + bombSVG . complete + '_' + redBorderOnBricksWithWrongColor + '_' + ballsColor ;
2025-02-15 19:21:00 +01:00
if ( newKey !== cachedBricksRenderKey ) {
cachedBricksRenderKey = newKey ;
cachedBricksRender . width = gameZoneWidth ;
cachedBricksRender . height = gameZoneWidth + 1 ;
const ctx = cachedBricksRender . getContext ( "2d" ) ;
ctx . clearRect ( 0 , 0 , gameZoneWidth , gameZoneWidth ) ;
ctx . resetTransform ( ) ;
ctx . translate ( - offsetX , 0 ) ;
// Bricks
2025-03-01 14:20:27 +01:00
const puckColor = level . black _puck ? '#000' : '#FFF'
2025-02-15 19:21:00 +01:00
bricks . forEach ( ( color , index ) => {
const x = brickCenterX ( index ) , y = brickCenterY ( index ) ;
if ( ! color ) return ;
2025-03-01 20:40:20 +01:00
const borderColor = ( ballsColor === color && puckColor ) || ( color !== 'black' && redBorderOnBricksWithWrongColor && 'red' ) || color
2025-03-01 14:20:27 +01:00
drawBrick ( ctx , color , borderColor , x , y ) ;
2025-02-15 19:21:00 +01:00
if ( color === 'black' ) {
ctx . globalCompositeOperation = "source-over" ;
drawIMG ( ctx , bombSVG , brickWidth , x , y ) ;
}
} ) ;
}
destinationCtx . drawImage ( cachedBricksRender , offsetX , 0 ) ;
}
let cachedGraphics = { } ;
2025-03-01 14:20:27 +01:00
function drawPuck ( ctx , color , puckWidth , puckHeight , yoffset = 0 ) {
2025-02-15 19:21:00 +01:00
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" ) ;
canctx . fillStyle = color ;
canctx . beginPath ( ) ;
canctx . moveTo ( 0 , puckHeight * 2 )
canctx . lineTo ( 0 , puckHeight * 1.25 )
canctx . bezierCurveTo ( 0 , puckHeight * . 75 , puckWidth , puckHeight * . 75 , puckWidth , puckHeight * 1.25 )
canctx . lineTo ( puckWidth , puckHeight * 2 )
canctx . fill ( ) ;
cachedGraphics [ key ] = can ;
}
2025-03-01 14:20:27 +01:00
ctx . drawImage ( cachedGraphics [ key ] , Math . round ( puck - puckWidth / 2 ) , gameZoneHeight - puckHeight * 2 + yoffset ) ;
2025-02-15 19:21:00 +01:00
}
2025-03-01 14:20:27 +01:00
function drawBall ( ctx , color , width , x , y , borderColor = '' ) {
const key = "ball" + color + "_" + width + '_' + borderColor ;
2025-02-15 19:21:00 +01:00
2025-03-01 14:20:27 +01:00
const size = Math . round ( width ) ;
2025-02-15 19:21:00 +01:00
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = size ;
can . height = size ;
const canctx = can . getContext ( "2d" ) ;
canctx . beginPath ( ) ;
2025-03-01 14:20:27 +01:00
canctx . arc ( size / 2 , size / 2 , Math . round ( size / 2 ) - 1 , 0 , 2 * Math . PI ) ;
2025-02-15 19:21:00 +01:00
canctx . fillStyle = color ;
canctx . fill ( ) ;
2025-03-01 14:20:27 +01:00
if ( borderColor ) {
canctx . lineWidth = 2
canctx . strokeStyle = borderColor
2025-02-27 22:19:50 +01:00
canctx . stroke ( )
}
2025-02-15 19:21:00 +01:00
cachedGraphics [ key ] = can ;
}
2025-02-27 22:19:50 +01:00
ctx . drawImage ( cachedGraphics [ key ] , Math . round ( x - size / 2 ) , Math . round ( y - size / 2 ) , ) ;
2025-02-15 19:21:00 +01:00
}
2025-03-01 14:20:27 +01:00
function drawCoin ( ctx , color , size , x , y , bg , rawAngle ) {
2025-02-27 22:19:50 +01:00
2025-03-01 14:20:27 +01:00
const angle = ( Math . round ( rawAngle / Math . PI * 2 * 16 ) % 16 + 16 ) % 16
const key = "coin with halo" + "_" + color + "_" + size + '_' + bg + '_' + angle ;
2025-02-15 19:21:00 +01:00
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = size ;
can . height = size ;
const canctx = can . getContext ( "2d" ) ;
// coin
canctx . beginPath ( ) ;
2025-02-26 23:03:12 +01:00
canctx . arc ( size / 2 , size / 2 , size / 2 , 0 , 2 * Math . PI ) ;
2025-02-15 19:21:00 +01:00
canctx . fillStyle = color ;
canctx . fill ( ) ;
2025-03-01 14:20:27 +01:00
if ( color === 'gold' ) {
2025-02-15 19:21:00 +01:00
2025-02-27 22:19:50 +01:00
canctx . strokeStyle = bg ;
canctx . stroke ( ) ;
2025-02-26 23:03:12 +01:00
2025-02-27 22:19:50 +01:00
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 ( ) ;
2025-02-26 23:03:12 +01:00
2025-02-27 22:19:50 +01:00
canctx . translate ( size / 2 , size / 2 ) ;
2025-03-01 14:20:27 +01:00
canctx . rotate ( angle / 16 ) ;
2025-02-27 22:19:50 +01:00
canctx . translate ( - size / 2 , - size / 2 ) ;
2025-02-26 23:03:12 +01:00
2025-03-01 14:20:27 +01:00
canctx . globalCompositeOperation = 'multiply'
drawText ( canctx , '$' , color , size - 2 , size / 2 , size / 2 + 1 )
drawText ( canctx , '$' , color , size - 2 , size / 2 , size / 2 + 1 )
2025-02-27 22:19:50 +01:00
}
2025-03-01 14:20:27 +01:00
cachedGraphics [ key ] = can ;
2025-02-15 19:21:00 +01:00
}
2025-02-26 23:03:12 +01:00
ctx . drawImage ( cachedGraphics [ key ] , Math . round ( x - size / 2 ) , Math . round ( y - size / 2 ) ) ;
2025-02-15 19:21:00 +01:00
}
function drawFuzzyBall ( ctx , color , width , x , y ) {
const key = "fuzzy-circle" + color + "_" + width ;
2025-03-01 20:40:20 +01:00
if ( ! color )
debugger
2025-02-15 19:21:00 +01:00
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" ) ;
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 ) , ) ;
}
2025-03-01 14:20:27 +01:00
function drawBrick ( ctx , color , borderColor , x , y ) {
2025-02-15 19:21:00 +01:00
const tlx = Math . ceil ( x - brickWidth / 2 ) ;
const tly = Math . ceil ( y - brickWidth / 2 ) ;
const brx = Math . ceil ( x + brickWidth / 2 ) - 1 ;
const bry = Math . ceil ( y + brickWidth / 2 ) - 1 ;
const width = brx - tlx , height = bry - tly ;
2025-03-01 14:20:27 +01:00
const key = "brick" + color + '_' + borderColor + "_" + width + "_" + height
2025-02-15 19:21:00 +01:00
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = width ;
can . height = height ;
2025-03-01 14:20:27 +01:00
const bord = 4 ;
2025-02-15 19:21:00 +01:00
const canctx = can . getContext ( "2d" ) ;
2025-03-01 14:20:27 +01:00
canctx . fillStyle = color ;
canctx . fillRect ( bord , bord , width - bord * 2 , height - bord * 2 ) ;
canctx . strokeStyle = borderColor ;
canctx . lineJoin = "round" ;
canctx . lineWidth = bord
canctx . strokeRect ( bord / 2 , bord / 2 , width - bord , height - bord ) ;
2025-02-15 19:21:00 +01:00
cachedGraphics [ key ] = can ;
}
ctx . drawImage ( cachedGraphics [ key ] , tlx , tly , width , height ) ;
// It's not easy to have a 1px gap between bricks without antialiasing
}
2025-03-01 14:20:27 +01:00
function drawRedSquare ( ctx , x , y , width , height ) {
ctx . fillStyle = 'red'
ctx . fillRect ( x , y , width , height ) ;
2025-02-15 19:21:00 +01:00
}
function drawIMG ( ctx , img , size , x , y ) {
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" ) ;
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 ) , ) ;
}
2025-03-01 14:20:27 +01:00
function drawText ( ctx , text , color , fontSize , x , y , left = false ) {
const key = "text" + text + "_" + color + "_" + fontSize + '_' + left ;
2025-02-15 19:21:00 +01:00
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = fontSize * text . length ;
can . height = fontSize ;
const canctx = can . getContext ( "2d" ) ;
canctx . fillStyle = color ;
2025-03-01 14:20:27 +01:00
canctx . textAlign = left ? 'left' : "center"
2025-02-15 19:21:00 +01:00
canctx . textBaseline = "middle" ;
canctx . font = fontSize + "px monospace" ;
2025-02-26 23:03:12 +01:00
canctx . fillText ( text , left ? 0 : can . width / 2 , can . height / 2 , can . width ) ;
2025-02-15 19:21:00 +01:00
cachedGraphics [ key ] = can ;
}
2025-03-01 14:20:27 +01:00
ctx . drawImage ( cachedGraphics [ key ] , left ? x : Math . round ( x - cachedGraphics [ key ] . width / 2 ) , Math . round ( y - cachedGraphics [ key ] . height / 2 ) , ) ;
2025-02-15 19:21:00 +01:00
}
function pixelsToPan ( pan ) {
return ( pan - offsetX ) / gameZoneWidth ;
}
let lastComboPlayed = NaN , shepard = 6 ;
function playShepard ( delta , pan , volume ) {
const shepardMax = 11 , factor = 1.05945594920268 , baseNote = 392 ;
shepard += delta ;
if ( shepard > shepardMax ) shepard = 0 ;
if ( shepard < 0 ) shepard = shepardMax ;
const play = ( note ) => {
const freq = baseNote * Math . pow ( factor , note ) ;
const diff = Math . abs ( note - shepardMax * 0.5 ) ;
const maxDistanceToIdeal = 1.5 * shepardMax ;
const vol = Math . max ( 0 , volume * ( 1 - diff / maxDistanceToIdeal ) ) ;
createSingleBounceSound ( freq , pan , vol ) ;
return freq . toFixed ( 2 ) + " at " + Math . floor ( vol * 100 ) + "% diff " + diff ;
} ;
play ( 1 + shepardMax + shepard ) ;
play ( shepard ) ;
play ( - 1 - shepardMax + shepard ) ;
}
const sounds = {
wallBeep : ( pan ) => {
if ( ! isSettingOn ( "sound" ) ) return ;
createSingleBounceSound ( 800 , pixelsToPan ( pan ) ) ;
} ,
comboIncreaseMaybe : ( x , volume ) => {
if ( ! isSettingOn ( "sound" ) ) return ;
let delta = 0 ;
if ( ! isNaN ( lastComboPlayed ) ) {
if ( lastComboPlayed < combo ) delta = 1 ;
if ( lastComboPlayed > combo ) delta = - 1 ;
}
playShepard ( delta , pixelsToPan ( x ) , volume ) ;
lastComboPlayed = combo ;
} ,
comboDecrease ( ) {
if ( ! isSettingOn ( "sound" ) ) return ;
playShepard ( - 1 , 0.5 , 0.5 ) ;
2025-02-21 12:35:42 +01:00
} ,
coinBounce : ( pan , volume ) => {
2025-02-15 19:21:00 +01:00
if ( ! isSettingOn ( "sound" ) ) return ;
2025-03-01 14:20:27 +01:00
createSingleBounceSound ( 1200 , pixelsToPan ( pan ) , volume , 0.1 , 'triangle' ) ;
2025-02-15 19:21:00 +01:00
} , explode : ( pan ) => {
if ( ! isSettingOn ( "sound" ) ) return ;
createExplosionSound ( pixelsToPan ( pan ) ) ;
} , revive : ( ) => {
if ( ! isSettingOn ( "sound" ) ) return ;
createRevivalSound ( 500 ) ;
} , coinCatch ( pan ) {
if ( ! isSettingOn ( "sound" ) ) return ;
2025-03-01 14:20:27 +01:00
createSingleBounceSound ( 900 , pixelsToPan ( pan ) , . 8 , 0.1 , 'triangle' )
2025-02-15 19:21:00 +01:00
}
} ;
// How to play the code on the leftconst context = new window.AudioContext();
2025-02-25 14:36:07 +01:00
let audioContext , audioRecordingTrack ;
2025-02-15 19:21:00 +01:00
function getAudioContext ( ) {
if ( ! audioContext ) {
audioContext = new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
2025-02-25 14:36:07 +01:00
audioRecordingTrack = audioContext . createMediaStreamDestination ( )
2025-02-15 19:21:00 +01:00
}
return audioContext ;
}
2025-02-26 23:03:12 +01:00
function createSingleBounceSound ( baseFreq = 800 , pan = 0.5 , volume = 1 , duration = 0.1 , type = "sine" ) {
2025-02-15 19:21:00 +01:00
const context = getAudioContext ( ) ;
// Frequency for the metal "ping"
const baseFrequency = baseFreq ; // Hz
// Create an oscillator for the impact sound
const oscillator = context . createOscillator ( ) ;
2025-03-01 14:20:27 +01:00
oscillator . type = type ;
2025-02-15 19:21:00 +01:00
oscillator . frequency . setValueAtTime ( baseFrequency , context . currentTime ) ;
// Create a gain node to control the volume
const gainNode = context . createGain ( ) ;
oscillator . connect ( gainNode ) ;
// Create a stereo panner node for left-right panning
const panner = context . createStereoPanner ( ) ;
panner . pan . setValueAtTime ( pan * 2 - 1 , context . currentTime ) ;
gainNode . connect ( panner ) ;
panner . connect ( context . destination ) ;
2025-02-25 14:36:07 +01:00
panner . connect ( audioRecordingTrack ) ;
2025-02-15 19:21:00 +01:00
// Set up the gain envelope to simulate the impact and quick decay
gainNode . gain . setValueAtTime ( 0.8 * volume , context . currentTime ) ; // Initial impact
gainNode . gain . exponentialRampToValueAtTime ( 0.001 , context . currentTime + duration , ) ; // Quick decay
// Start the oscillator
oscillator . start ( context . currentTime ) ;
// Stop the oscillator after the decay
oscillator . stop ( context . currentTime + duration ) ;
}
function createRevivalSound ( baseFreq = 440 ) {
const context = getAudioContext ( ) ;
// Create multiple oscillators for a richer sound
const oscillators = [ context . createOscillator ( ) , context . createOscillator ( ) , context . createOscillator ( ) , ] ;
// Set the type and frequency for each oscillator
oscillators . forEach ( ( osc , index ) => {
osc . type = "sine" ;
osc . frequency . setValueAtTime ( baseFreq + index * 2 , context . currentTime ) ; // Slight detuning
} ) ;
// Create a gain node to control the volume
const gainNode = context . createGain ( ) ;
// Connect all oscillators to the gain node
oscillators . forEach ( ( osc ) => osc . connect ( gainNode ) ) ;
// Create a stereo panner node for left-right panning
const panner = context . createStereoPanner ( ) ;
panner . pan . setValueAtTime ( 0 , context . currentTime ) ; // Center panning
gainNode . connect ( panner ) ;
panner . connect ( context . destination ) ;
2025-02-25 14:36:07 +01:00
panner . connect ( audioRecordingTrack ) ;
2025-02-15 19:21:00 +01:00
// Set up the gain envelope to simulate a smooth attack and decay
gainNode . gain . setValueAtTime ( 0 , context . currentTime ) ; // Start at zero
gainNode . gain . linearRampToValueAtTime ( 0.8 , context . currentTime + 0.5 ) ; // Ramp up to full volume
gainNode . gain . exponentialRampToValueAtTime ( 0.001 , context . currentTime + 2 ) ; // Slow decay
// Start all oscillators
oscillators . forEach ( ( osc ) => osc . start ( context . currentTime ) ) ;
// Stop all oscillators after the decay
oscillators . forEach ( ( osc ) => osc . stop ( context . currentTime + 2 ) ) ;
}
let noiseBuffer ;
function createExplosionSound ( pan = 0.5 ) {
const context = getAudioContext ( ) ;
// Create an audio buffer
if ( ! noiseBuffer ) {
const bufferSize = context . sampleRate * 2 ; // 2 seconds
noiseBuffer = context . createBuffer ( 1 , bufferSize , context . sampleRate ) ;
const output = noiseBuffer . getChannelData ( 0 ) ;
// Fill the buffer with random noise
for ( let i = 0 ; i < bufferSize ; i ++ ) {
output [ i ] = Math . random ( ) * 2 - 1 ;
}
}
// Create a noise source
const noiseSource = context . createBufferSource ( ) ;
noiseSource . buffer = noiseBuffer ;
// Create a gain node to control the volume
const gainNode = context . createGain ( ) ;
noiseSource . connect ( gainNode ) ;
// Create a filter to shape the explosion sound
const filter = context . createBiquadFilter ( ) ;
filter . type = "lowpass" ;
filter . frequency . setValueAtTime ( 1000 , context . currentTime ) ; // Set the initial frequency
gainNode . connect ( filter ) ;
// Create a stereo panner node for left-right panning
const panner = context . createStereoPanner ( ) ;
panner . pan . setValueAtTime ( pan * 2 - 1 , context . currentTime ) ; // pan 0 to 1 maps to -1 to 1
// Connect filter to panner and then to the destination (speakers)
filter . connect ( panner ) ;
panner . connect ( context . destination ) ;
2025-02-25 14:36:07 +01:00
panner . connect ( audioRecordingTrack ) ;
2025-02-15 19:21:00 +01:00
// Ramp down the gain to simulate the explosion's fade-out
gainNode . gain . setValueAtTime ( 1 , context . currentTime ) ;
gainNode . gain . exponentialRampToValueAtTime ( 0.01 , context . currentTime + 1 ) ;
// Lower the filter frequency over time to create the "explosive" effect
filter . frequency . exponentialRampToValueAtTime ( 60 , context . currentTime + 1 ) ;
// Start the noise source
noiseSource . start ( context . currentTime ) ;
// Stop the noise source after the sound has played
noiseSource . stop ( context . currentTime + 1 ) ;
}
let levelTime = 0 ;
setInterval ( ( ) => {
2025-02-19 21:11:22 +01:00
document . body . className = ( running ? " running " : " paused " ) + ( currentLevelInfo ( ) ? . black _puck ? ' black_puck ' : ' ' ) ;
2025-02-15 19:21:00 +01:00
} , 100 ) ;
window . addEventListener ( "visibilitychange" , ( ) => {
if ( document . hidden ) {
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-15 19:21:00 +01:00
}
} ) ;
const scoreDisplay = document . getElementById ( "score" ) ;
2025-03-01 14:20:27 +01:00
let alertsOpen = 0 , closeModal = null
2025-02-15 19:21:00 +01:00
function asyncAlert ( {
title ,
text ,
actions = [ { text : "OK" , value : "ok" , help : "" } ] ,
allowClose = true ,
textAfterButtons = ''
} ) {
2025-02-27 18:56:04 +01:00
alertsOpen ++
2025-02-15 19:21:00 +01:00
return new Promise ( ( resolve ) => {
const popupWrap = document . createElement ( "div" ) ;
document . body . appendChild ( popupWrap ) ;
popupWrap . className = "popup" ;
function closeWithResult ( value ) {
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 = "close"
closeButton . className = "close-modale"
closeButton . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( )
closeWithResult ( null )
} )
2025-03-01 14:20:27 +01:00
closeModal = ( ) => {
2025-02-27 18:56:04 +01:00
closeWithResult ( null )
}
2025-02-15 19:21:00 +01:00
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 ) ;
}
2025-02-19 12:37:21 +01:00
actions . filter ( i => i ) . forEach ( ( { text , value , help , checked = 0 , max = 0 , disabled , icon = '' } ) => {
2025-02-15 19:21:00 +01:00
const button = document . createElement ( "button" ) ;
let checkMark = ''
if ( max ) {
checkMark += '<span class="checks">'
for ( let i = 0 ; i < max ; i ++ ) {
checkMark += '<span class="' + ( checked > i ? "checked" : "unchecked" ) + '"></span>' ;
}
checkMark += '</span>'
}
2025-02-19 12:37:21 +01:00
button . innerHTML = `
$ { icon }
$ { checkMark }
2025-02-15 19:21:00 +01:00
< div >
< strong > $ { text } < / s t r o n g >
< em > $ { help || '' } < / e m >
< / d i v > ` ;
if ( disabled ) {
button . setAttribute ( "disabled" , "disabled" ) ;
} else {
button . addEventListener ( "click" , ( e ) => {
e . preventDefault ( ) ;
closeWithResult ( value )
} ) ;
}
popup . appendChild ( button ) ;
} ) ;
if ( textAfterButtons ) {
const p = document . createElement ( "div" ) ;
p . className = 'textAfterButtons'
p . innerHTML = textAfterButtons ;
popup . appendChild ( p ) ;
}
popupWrap . appendChild ( popup ) ;
2025-03-01 14:20:27 +01:00
popup . querySelector ( 'button:not([disabled])' ) ? . focus ( )
} ) . finally ( ( ) => {
2025-02-27 18:56:04 +01:00
closeModal = null
alertsOpen --
} )
2025-02-15 19:21:00 +01:00
}
// Settings
let cachedSettings = { } ;
function isSettingOn ( key ) {
if ( typeof cachedSettings [ key ] == "undefined" ) {
try {
cachedSettings [ key ] = JSON . parse ( localStorage . getItem ( "breakout-settings-enable-" + key ) , ) ;
} catch ( e ) {
console . warn ( e ) ;
}
}
return cachedSettings [ key ] ? ? options [ key ] ? . default ? ? false ;
}
function toggleSetting ( key ) {
cachedSettings [ key ] = ! isSettingOn ( key ) ;
try {
const lskey = "breakout-settings-enable-" + key ;
localStorage . setItem ( lskey , JSON . stringify ( cachedSettings [ key ] ) ) ;
} catch ( e ) {
console . warn ( e ) ;
}
if ( options [ key ] . afterChange ) options [ key ] . afterChange ( ) ;
}
2025-02-27 18:56:04 +01:00
2025-02-15 19:21:00 +01:00
scoreDisplay . addEventListener ( "click" , async ( e ) => {
e . preventDefault ( ) ;
2025-03-01 14:20:27 +01:00
openScorePanel ( )
2025-02-27 18:56:04 +01:00
} ) ;
2025-03-01 14:20:27 +01:00
async function openScorePanel ( ) {
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-15 19:21:00 +01:00
const cb = await asyncAlert ( {
2025-02-19 12:37:21 +01:00
title : ` ${ score } points at level ${ currentLevel + 1 } / ${ max _levels ( ) } ` ,
text : `
2025-02-24 21:04:31 +01:00
< p > Upgrades picked so far : < / p >
< p > $ { pickedUpgradesHTMl ( ) } < / p >
2025-02-27 18:56:04 +01:00
` , allowClose: true, actions: [
{
2025-03-01 14:20:27 +01:00
text : 'Resume' ,
help : "Return to your run" ,
2025-02-27 18:56:04 +01:00
} ,
{
2025-03-01 14:20:27 +01:00
text : "Restart" , help : "Start a brand new run." ,
2025-02-27 18:56:04 +01:00
value : ( ) => {
2025-03-01 14:20:27 +01:00
restart ( ) ;
return true ;
} ,
} ] ,
2025-02-15 19:21:00 +01:00
} ) ;
if ( cb ) {
await cb ( )
}
2025-02-27 18:56:04 +01:00
}
2025-02-15 19:21:00 +01:00
document . getElementById ( "menu" ) . addEventListener ( "click" , ( e ) => {
e . preventDefault ( ) ;
openSettingsPanel ( ) ;
} ) ;
2025-02-20 12:07:42 +01:00
2025-02-15 19:21:00 +01:00
const options = {
sound : {
default : true , name : ` Game sounds ` , help : ` Can slow down some phones. ` ,
2025-02-23 21:17:22 +01:00
disabled : ( ) => false
2025-02-15 19:21:00 +01:00
} , "mobile-mode" : {
default : window . innerHeight > window . innerWidth ,
name : ` Mobile mode ` ,
help : ` Leaves space for your thumb. ` ,
afterChange ( ) {
fitSize ( ) ;
} ,
2025-02-23 21:17:22 +01:00
disabled : ( ) => false
2025-02-15 19:21:00 +01:00
} ,
basic : {
2025-02-24 21:04:31 +01:00
default : false , name : ` Basic graphics ` , help : ` Better performance on older devices. ` ,
2025-02-23 21:17:22 +01:00
disabled : ( ) => false
2025-02-15 19:21:00 +01:00
} ,
2025-02-27 19:11:31 +01:00
pointerLock : {
default : false , name : ` Pointer lock ` ,
help : ` Locks and hides the mouse cursor. ` ,
disabled : ( ) => ! canvas . requestPointerLock
} ,
2025-02-15 19:21:00 +01:00
"easy" : {
2025-02-24 21:04:31 +01:00
default : false , name : ` Kids mode ` , help : ` Starting perk always "slower ball". ` , restart : true ,
2025-02-23 21:17:22 +01:00
disabled : ( ) => false
2025-02-15 19:21:00 +01:00
} ,
2025-02-20 11:34:11 +01:00
// 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
2025-02-23 21:17:22 +01:00
"record" : {
2025-02-20 12:07:42 +01:00
default : false , name : ` Record gameplay videos ` , help : ` Get a video of each level. ` ,
2025-02-23 21:17:22 +01:00
disabled ( ) {
return window . location . search . includes ( 'isInWebView=true' )
2025-02-20 12:07:42 +01:00
}
2025-02-20 11:34:11 +01:00
} ,
2025-02-23 21:17:22 +01:00
gif : {
2025-02-20 12:07:42 +01:00
default : false , name : ` Make a gif too ` , help : ` 3x heavier, 2x smaller, 7s max ` ,
2025-02-23 21:17:22 +01:00
disabled ( ) {
return window . location . protocol === "file:" || ! isSettingOn ( 'record' )
2025-02-20 12:07:42 +01:00
}
}
2025-02-15 19:21:00 +01:00
} ;
async function openSettingsPanel ( ) {
2025-02-20 12:07:42 +01:00
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-15 19:21:00 +01:00
const optionsList = [ ] ;
for ( const key in options ) {
2025-02-23 21:17:22 +01:00
if ( options [ key ] )
2025-02-20 11:34:11 +01:00
optionsList . push ( {
2025-02-23 21:17:22 +01:00
disabled : options [ key ] . disabled ( ) ,
2025-02-20 11:34:11 +01:00
checked : isSettingOn ( key ) ? 1 : 0 ,
max : 1 , text : options [ key ] . name , help : options [ key ] . help , value : ( ) => {
toggleSetting ( key )
if ( options [ key ] . restart ) {
restart ( )
} else {
openSettingsPanel ( ) ;
}
} ,
} ) ;
2025-02-15 19:21:00 +01:00
}
const cb = await asyncAlert ( {
title : "Breakout 71" , text : `
` , allowClose: true, actions: [
2025-02-27 18:56:04 +01:00
{
2025-03-01 14:20:27 +01:00
text : 'Resume' ,
help : "Return to your run" ,
2025-02-27 18:56:04 +01:00
async value ( ) {
}
} ,
2025-02-17 00:49:03 +01:00
{
2025-03-01 14:20:27 +01:00
text : 'Unlocks and help' ,
2025-02-27 23:04:42 +01:00
help : "See perks and levels you unlocked" ,
2025-02-17 00:49:03 +01:00
async value ( ) {
const ts = getTotalScore ( )
2025-02-19 21:11:22 +01:00
const actions = [ ... upgrades
. sort ( ( a , b ) => a . threshold - b . threshold )
. map ( ( {
name ,
max ,
help , id ,
2025-03-01 14:36:44 +01:00
threshold , icon , tryout , fullHelp
2025-02-19 21:11:22 +01:00
} ) => ( {
text : name ,
2025-03-01 14:20:27 +01:00
help : ts >= threshold ? fullHelp || help : ` Unlocks at total score ${ threshold } . ` ,
2025-02-19 21:11:22 +01:00
disabled : ts < threshold ,
value : tryout || { perks : { [ id ] : max } } ,
icon
} )
)
,
... allLevels
2025-02-17 01:07:20 +01:00
. sort ( ( a , b ) => a . threshold - b . threshold )
2025-02-19 21:11:22 +01:00
. map ( ( l , li ) => {
const avaliable = ts >= l . threshold
return ( {
text : l . name ,
help : avaliable ? ` A ${ l . size } x ${ l . size } level with ${ l . bricks . filter ( i => i ) . length } bricks ` : ` Unlocks at total score ${ l . threshold } . ` ,
disabled : ! avaliable ,
value : { level : l . name } ,
icon : levelIconHTML ( l )
2025-02-17 01:07:20 +01:00
} )
2025-02-19 21:11:22 +01:00
} )
]
2025-02-17 00:49:03 +01:00
2025-02-19 12:37:21 +01:00
const tryOn = await asyncAlert ( {
2025-02-19 21:11:22 +01:00
title : ` You unlocked ${ Math . round ( actions . filter ( a => ! a . disabled ) . length / actions . length * 100 ) } % of the game. ` ,
2025-02-19 12:37:21 +01:00
text : `
< p > Your total score is $ { ts } . Below are all the upgrades and levels the games has to offer . They greyed out ones can be unlocked by increasing your total score . < / p >
` ,
2025-02-19 21:11:22 +01:00
textAfterButtons : ` <p>
2025-02-19 12:37:21 +01:00
The total score increases every time you score in game .
Your high score is $ { highScore } .
Click an item above to start a test run with it .
< / p > ` ,
2025-03-01 14:20:27 +01:00
actions ,
2025-02-17 00:49:03 +01:00
allowClose : true ,
} )
if ( tryOn ) {
2025-03-01 14:20:27 +01:00
if ( ! currentLevel || await asyncAlert ( {
title : 'Restart run to try this item?' ,
text : 'You\'re about to start a new test run with just the selected unlocked item, is that really what you wanted ? ' ,
actions : [ {
2025-02-26 19:38:09 +01:00
value : true ,
2025-03-01 14:20:27 +01:00
text : 'Restart game to test item'
} , {
value : false ,
text : 'Cancel'
2025-02-26 19:38:09 +01:00
} ]
} ) )
2025-03-01 14:20:27 +01:00
nextRunOverrides = tryOn
2025-02-17 00:49:03 +01:00
restart ( )
}
}
} ,
2025-02-15 19:21:00 +01:00
... optionsList ,
2025-02-26 21:03:39 +01:00
( document . fullscreenEnabled || document . webkitFullscreenEnabled ) &&
( document . fullscreenElement !== null ? {
2025-02-23 21:17:22 +01:00
text : "Exit Fullscreen" ,
help : "Might not work on some machines" ,
value ( ) {
2025-03-01 14:20:27 +01:00
toggleFullScreen ( )
2025-02-21 12:52:31 +01:00
}
2025-02-23 21:17:22 +01:00
} :
{
text : "Fullscreen" ,
help : "Might not work on some machines" ,
value ( ) {
2025-03-01 14:20:27 +01:00
toggleFullScreen ( )
2025-02-15 19:21:00 +01:00
}
2025-02-23 21:17:22 +01:00
} ) ,
2025-02-15 19:21:00 +01:00
{
text : 'Reset Game' ,
help : "Erase high score and statistics" ,
async value ( ) {
if ( await asyncAlert ( {
title : 'Reset' ,
actions : [
{
text : 'Yes' ,
value : true
} ,
{
text : 'No' ,
value : false
}
] ,
allowClose : true ,
} ) ) {
localStorage . clear ( )
window . location . reload ( )
}
}
}
] ,
textAfterButtons : `
2025-02-21 12:35:42 +01:00
< p >
< span > Made in France by < a href = "https://lecaro.me" > Renan LE CARO < / a > . < / s p a n >
< a href = "./privacy.html" target = "_blank" > Privacy Policy < / a >
2025-02-25 09:18:43 +01:00
< a href = "https://f-droid.org/en/packages/me.lecaro.breakout/" target = "_blank" > F - Droid < / a >
2025-02-21 12:35:42 +01:00
< a href = "https://play.google.com/store/apps/details?id=me.lecaro.breakout" target = "_blank" > Google Play < / a >
2025-02-25 09:18:43 +01:00
< a href = "https://renanlecaro.itch.io/breakout71" target = "_blank" > itch . io < / a >
2025-02-21 12:35:42 +01:00
< a href = "https://gitlab.com/lecarore/breakout71" target = "_blank" > Gitlab < / a >
< a href = "https://breakout.lecaro.me/" target = "_blank" > Web version < / a >
2025-02-26 23:36:08 +01:00
< a href = "https://news.ycombinator.com/item?id=43183131" target = "_blank" > HackerNews < / a >
2025-02-21 12:35:42 +01:00
< span > v . $ { window . appVersion } < / s p a n >
2025-02-15 19:21:00 +01:00
< / p >
`
} )
if ( cb ) {
cb ( )
}
}
2025-02-17 00:49:03 +01:00
function distance2 ( a , b ) {
return Math . pow ( a . x - b . x , 2 ) + Math . pow ( a . y - b . y , 2 )
2025-02-16 21:21:12 +01:00
}
2025-02-17 00:49:03 +01:00
function distanceBetween ( a , b ) {
return Math . sqrt ( distance2 ( a , b ) )
2025-02-16 21:21:12 +01:00
}
2025-02-17 00:49:03 +01:00
function rainbowColor ( ) {
2025-02-19 21:11:22 +01:00
return ` hsl( ${ Math . round ( ( levelTime / 4 ) ) * 2 % 360 } ,100%,70%) `
2025-02-17 00:49:03 +01:00
}
function repulse ( a , b , power , impactsBToo ) {
2025-02-16 21:21:12 +01:00
2025-02-17 00:49:03 +01:00
const distance = distanceBetween ( a , b )
2025-02-16 21:21:12 +01:00
// Ensure we don't get soft locked
2025-02-17 00:49:03 +01:00
const max = gameZoneWidth / 2
if ( distance > max ) return
2025-02-16 21:21:12 +01:00
// Unit vector
2025-02-17 00:49:03 +01:00
const dx = ( a . x - b . x ) / distance
const dy = ( a . y - b . y ) / distance
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
2025-02-23 21:17:22 +01:00
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 ,
} )
if ( impactsBToo ) {
2025-02-16 21:21:12 +01:00
flashes . push ( {
type : "particle" ,
2025-02-17 00:49:03 +01:00
duration : 100 ,
2025-02-16 21:21:12 +01:00
time : levelTime ,
2025-02-17 00:49:03 +01:00
size : coinSize / 2 ,
color : rainbowColor ( ) ,
ethereal : true ,
2025-02-23 21:17:22 +01:00
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 ,
2025-02-17 00:49:03 +01:00
} )
2025-02-23 21:17:22 +01:00
}
2025-02-17 00:49:03 +01:00
}
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
2025-02-23 21:17:22 +01:00
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 : 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 ,
} )
2025-02-19 12:37:21 +01:00
}
let levelIconHTMLCanvas = document . createElement ( 'canvas' )
const levelIconHTMLCanvasCtx = levelIconHTMLCanvas . getContext ( "2d" , { antialias : false , alpha : true } )
2025-02-16 21:21:12 +01:00
2025-02-19 21:11:22 +01:00
function levelIconHTML ( level , title ) {
const size = 40
2025-02-19 12:37:21 +01:00
const c = levelIconHTMLCanvas
const ctx = levelIconHTMLCanvasCtx
c . width = size
c . height = size
if ( level . color ) {
ctx . fillStyle = level . color
ctx . fillRect ( 0 , 0 , size , size )
} else {
ctx . clearRect ( 0 , 0 , size , size )
}
const pxSize = size / level . size
for ( let x = 0 ; x < level . size ; x ++ ) {
for ( let y = 0 ; y < level . size ; y ++ ) {
const c = level . bricks [ y * level . size + x ]
if ( c ) {
ctx . fillStyle = c
ctx . fillRect ( Math . floor ( pxSize * x ) , Math . floor ( pxSize * y ) , Math . ceil ( pxSize ) , Math . ceil ( pxSize ) )
}
}
}
// I don't think many blind people will benefit for this but it's nice to have something to put in "alt"
return ` <img title=" ${ title || level . name } " alt="Icon for ${ level . name } " width=" ${ size } " height=" ${ size } " src=" ${ c . toDataURL ( ) } "/> `
2025-02-16 21:21:12 +01:00
}
2025-02-17 00:49:03 +01:00
2025-02-19 21:11:22 +01:00
upgrades . forEach ( u => u . icon = levelIconHTML ( perkIconsLevels [ u . id ] , u . name ) )
2025-02-19 12:37:21 +01:00
2025-02-20 11:34:11 +01:00
let mediaRecorder , captureStream , recordCanvas , recordCanvasCtx , levelGif , gifCanvas , gifCtx
function recordOneFrame ( ) {
if ( ! isSettingOn ( 'record' ) ) {
return
}
if ( ! running ) return ;
2025-03-01 14:36:44 +01:00
if ( ! captureStream ) return ;
2025-02-20 11:34:11 +01:00
drawMainCanvasOnSmallCanvas ( )
// Start recording after you hit something
2025-02-20 11:38:38 +01:00
if ( levelSpawnedCoins && levelGif ) {
2025-02-20 11:34:11 +01:00
recordGifFrame ( )
}
if ( captureStream . requestFrame ) {
captureStream . requestFrame ( )
} else {
captureStream . getVideoTracks ( ) [ 0 ] . requestFrame ( )
}
}
function drawMainCanvasOnSmallCanvas ( ) {
2025-03-01 14:20:27 +01:00
if ( ! recordCanvasCtx ) return
2025-02-24 21:04:31 +01:00
recordCanvasCtx . drawImage ( canvas , offsetXRoundedDown , 0 , gameZoneWidthRoundedUp , gameZoneHeight , 0 , 0 , recordCanvas . width , recordCanvas . height )
2025-02-20 11:34:11 +01:00
recordCanvasCtx . fillStyle = currentLevelInfo ( ) ? . black _puck ? '#000' : '#FFF'
recordCanvasCtx . textBaseline = "top" ;
recordCanvasCtx . font = "12px monospace" ;
recordCanvasCtx . textAlign = "right" ;
recordCanvasCtx . fillText ( score . toString ( ) , recordCanvas . width - 12 , 12 )
2025-02-25 14:36:07 +01:00
2025-02-20 11:34:11 +01:00
recordCanvasCtx . textAlign = "left" ;
2025-03-01 14:20:27 +01:00
recordCanvasCtx . fillText ( 'Level ' + ( currentLevel + 1 ) + '/' + max _levels ( ) , 12 , 12 )
2025-02-20 11:34:11 +01:00
}
2025-02-20 12:07:42 +01:00
let nthGifFrame = 0 , gifFrameReduction = 2
2025-02-20 11:38:38 +01:00
function recordGifFrame ( ) {
2025-02-23 21:17:22 +01:00
if ( nthGifFrame / 60 > 7 ) return
2025-02-20 11:34:11 +01:00
gifCtx . globalCompositeOperation = 'screen'
gifCtx . globalAlpha = 1 / gifFrameReduction
gifCtx ? . drawImage ( canvas , offsetXRoundedDown , 0 , gameZoneWidthRoundedUp , gameZoneHeight , 0 , 0 , gifCanvas . width , gifCanvas . height )
2025-02-20 12:07:42 +01:00
nthGifFrame ++
if ( ! ( nthGifFrame % gifFrameReduction ) ) {
2025-02-20 11:34:11 +01:00
levelGif . addFrame ( gifCtx , { delay : Math . round ( gifFrameReduction * 1000 / 60 ) , copy : true } ) ;
gifCtx . globalCompositeOperation = 'source-over'
gifCtx . fillStyle = 'black'
gifCtx . fillRect ( 0 , 0 , gifCanvas . width , gifCanvas . height )
2025-02-20 12:07:42 +01:00
2025-02-20 11:34:11 +01:00
}
}
function startRecordingGame ( ) {
if ( ! isSettingOn ( 'record' ) ) {
return
}
if ( ! recordCanvas ) {
// Smaller canvas with less details
recordCanvas = document . createElement ( "canvas" )
recordCanvasCtx = recordCanvas . getContext ( "2d" , { antialias : false , alpha : false } )
gifCanvas = document . createElement ( "canvas" )
gifCtx = gifCanvas . getContext ( "2d" , { antialias : false , alpha : false } )
2025-02-25 14:36:07 +01:00
2025-03-01 14:20:27 +01:00
captureStream = recordCanvas . captureStream ( 0 ) ;
2025-02-25 14:36:07 +01:00
2025-03-01 14:20:27 +01:00
if ( isSettingOn ( 'sound' ) && getAudioContext ( ) && audioRecordingTrack ) {
2025-02-25 14:36:07 +01:00
captureStream . addTrack ( audioRecordingTrack . stream . getAudioTracks ( ) [ 0 ] )
// captureStream.addTrack(audioRecordingTrack.stream.getAudioTracks()[1])
}
2025-02-20 11:34:11 +01:00
}
2025-02-24 21:04:31 +01:00
recordCanvas . width = gameZoneWidthRoundedUp
recordCanvas . height = gameZoneHeight
gifCanvas . width = 400
2025-03-01 14:20:27 +01:00
gifCanvas . height = Math . floor ( gameZoneHeight * ( 400 / gameZoneWidthRoundedUp ) )
2025-02-20 11:34:11 +01:00
2025-02-21 00:30:28 +01:00
2025-02-20 11:38:38 +01:00
// Gif worker won't work there
2025-02-20 12:07:42 +01:00
if ( window . location . protocol !== "file:" && isSettingOn ( 'gif' ) ) {
nthGifFrame = 0
2025-02-20 11:34:11 +01:00
levelGif = new GIF ( {
workers : 2 ,
quality : 10 ,
repeat : 0 ,
background : currentLevelInfo ( ) ? . color || '#000' ,
width : gifCanvas . width ,
height : gifCanvas . height ,
dither : false ,
2025-02-20 11:38:38 +01:00
} ) ;
2025-02-23 21:17:22 +01:00
} else {
levelGif = null
2025-02-20 11:38:38 +01:00
}
2025-02-20 11:34:11 +01:00
// drawMainCanvasOnSmallCanvas()
const recordedChunks = [ ] ;
2025-02-25 14:36:07 +01:00
2025-02-20 11:34:11 +01:00
const instance = new MediaRecorder ( captureStream ) ;
mediaRecorder = instance
instance . start ( ) ;
mediaRecorder . pause ( )
instance . ondataavailable = function ( event ) {
recordedChunks . push ( event . data ) ;
}
instance . onstop = async function ( ) {
2025-03-01 14:20:27 +01:00
let targetDiv ;
2025-02-24 21:04:31 +01:00
let blob = new Blob ( recordedChunks , { type : "video/webm" } ) ;
2025-03-01 14:20:27 +01:00
if ( blob . size < 200000 ) return // under 0.2MB, probably bugged out or pointlessly short
2025-02-24 21:04:31 +01:00
2025-03-01 14:20:27 +01:00
while ( ! ( targetDiv = document . getElementById ( "level-recording-container" ) ) ) {
await new Promise ( r => setTimeout ( r , 200 ) )
2025-02-24 21:04:31 +01:00
}
2025-02-20 11:34:11 +01:00
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
2025-02-25 14:36:07 +01:00
// targetDiv.style.width = recordCanvas.width + 'px'
// targetDiv.style.height = recordCanvas.height + 'px'
2025-02-20 11:34:11 +01:00
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 = ` Download video ( ${ ( blob . size / 1000000 ) . toFixed ( 2 ) } MB) `
targetDiv . appendChild ( video )
targetDiv . appendChild ( a )
}
levelGif ? . on ( 'finished' , function ( blob ) {
let targetDiv = document . getElementById ( "level-recording-container" )
const url = URL . createObjectURL ( blob )
const img = document . createElement ( "img" )
img . src = url
targetDiv ? . appendChild ( img )
const giflink = document . createElement ( "a" )
giflink . textContent = ` Download GIF ( ${ ( blob . size / 1000000 ) . toFixed ( 2 ) } MB) `
giflink . href = url
giflink . download = captureFileName ( 'gif' )
targetDiv ? . appendChild ( giflink )
} )
}
function pauseRecording ( ) {
if ( ! isSettingOn ( 'record' ) ) {
return
}
if ( mediaRecorder ? . state === 'recording' ) {
mediaRecorder ? . pause ( )
}
}
function resumeRecording ( ) {
if ( ! isSettingOn ( 'record' ) ) {
return
}
if ( mediaRecorder ? . state === 'paused' ) {
mediaRecorder . resume ( )
}
}
function stopRecording ( ) {
if ( ! isSettingOn ( 'record' ) ) {
return
}
if ( ! mediaRecorder ) return ;
mediaRecorder ? . stop ( )
levelGif ? . render ( )
mediaRecorder = null
levelGif = null
}
function captureFileName ( ext ) {
return "breakout-71-capture-" + new Date ( ) . toISOString ( ) . replace ( /[^0-9\-]+/gi , '-' ) + '.' + ext
}
2025-03-01 14:20:27 +01:00
function findLast ( arr , predicate ) {
let i = arr . length
while ( -- i )
if ( predicate ( arr [ i ] , i , arr ) ) {
return arr [ i ]
}
2025-02-23 21:17:22 +01:00
2025-03-01 14:20:27 +01:00
}
2025-02-23 21:17:22 +01:00
2025-03-01 14:20:27 +01:00
function toggleFullScreen ( ) {
try {
if ( document . fullscreenElement !== null ) {
if ( document . exitFullscreen ) {
document . exitFullscreen ( ) ;
} else if ( document . webkitCancelFullScreen ) {
document . webkitCancelFullScreen ( ) ;
2025-02-26 20:44:47 +01:00
}
2025-03-01 14:20:27 +01:00
} else {
const docel = document . documentElement
if ( docel . requestFullscreen ) {
docel . requestFullscreen ( ) ;
} else if ( docel . webkitRequestFullscreen ) {
docel . webkitRequestFullscreen ( ) ;
2025-02-27 18:56:04 +01:00
}
}
2025-03-01 14:20:27 +01:00
} catch ( e ) {
console . warn ( e )
}
2025-02-27 18:56:04 +01:00
}
2025-03-01 14:20:27 +01:00
const pressed = {
ArrowLeft : 0 ,
ArrowRight : 0 ,
Shift : 0
2025-02-27 18:56:04 +01:00
}
2025-03-01 14:20:27 +01:00
function setKeyPressed ( key , on ) {
pressed [ key ] = on
keyboardPuckSpeed = ( pressed . ArrowRight - pressed . ArrowLeft ) * ( 1 + pressed . Shift * 2 ) * gameZoneWidth / 50
2025-02-27 18:56:04 +01:00
}
2025-03-01 14:20:27 +01:00
document . addEventListener ( 'keydown' , e => {
if ( e . key . toLowerCase ( ) === 'f' ) {
2025-02-27 18:56:04 +01:00
toggleFullScreen ( )
2025-03-01 14:20:27 +01:00
} else if ( e . key in pressed ) {
setKeyPressed ( e . key , 1 )
}
if ( e . key === ' ' && ! alertsOpen ) {
if ( running ) {
2025-02-27 18:56:04 +01:00
pause ( )
} else {
play ( )
}
2025-03-01 14:20:27 +01:00
} else {
2025-02-27 18:56:04 +01:00
return
}
e . preventDefault ( )
} )
2025-03-01 14:20:27 +01:00
document . addEventListener ( 'keyup' , e => {
if ( e . key in pressed ) {
setKeyPressed ( e . key , 0 )
} else if ( e . key === 'ArrowDown' && document . querySelector ( 'button:focus' ) ? . nextElementSibling . tagName === 'BUTTON' ) {
document . querySelector ( 'button:focus' ) ? . nextElementSibling ? . focus ( )
} else if ( e . key === 'ArrowUp' && document . querySelector ( 'button:focus' ) ? . previousElementSibling . tagName === 'BUTTON' ) {
document . querySelector ( 'button:focus' ) ? . previousElementSibling ? . focus ( )
} else if ( e . key === 'Escape' && closeModal ) {
closeModal ( )
} else if ( e . key === 'Escape' && running ) {
pause ( )
} else if ( e . key . toLowerCase ( ) === 'm' && ! alertsOpen ) {
openSettingsPanel ( )
} else if ( e . key . toLowerCase ( ) === 's' && ! alertsOpen ) {
openScorePanel ( )
} else {
2025-02-27 18:56:04 +01:00
return
}
e . preventDefault ( )
} )
2025-02-23 21:17:22 +01:00
2025-02-15 19:21:00 +01:00
fitSize ( )
restart ( )
tick ( ) ;