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-02-25 14:36:07 +01:00
allLevels . forEach ( l => {
if ( ! l . color && ! l . svg ) {
2025-02-25 21:23:37 +01:00
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 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 ( ) {
return 1 + perks . base _combo * 3 ;
}
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
pauseTimeout = setTimeout ( ( ) => {
running = false
needsRender = true
if ( audioContext ) {
setTimeout ( ( ) => {
if ( ! running )
audioContext . suspend ( )
} , 1000 )
}
pauseRecording ( )
pauseTimeout = null
} , Math . min ( Math . max ( 0 , pauseUsesDuringRun - 5 ) * 50 , 500 ) )
if ( playerAskedForPause ) {
// 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-02-27 19:11:31 +01:00
if ( document . exitPointerLock ) {
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 getRowCol ( index ) {
return {
col : index % gridSize , row : Math . floor ( index / gridSize ) ,
} ;
}
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-02-25 21:23:37 +01:00
if ( flashes . length > MAX _PARTICLES ) {
// 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-02-23 21:17:22 +01:00
runStatistics . score += coin . points
2025-02-15 19:21:00 +01:00
}
let balls = [ ] ;
function resetBalls ( ) {
const count = 1 + ( perks ? . multiball || 0 ) ;
const perBall = puckWidth / ( count + 1 ) ;
balls = [ ] ;
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 ,
color : currentLevelInfo ( ) ? . black _puck ? '#000' : "#FFF" ,
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 ) ;
Object . assign ( ball , {
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 ,
2025-02-23 21:17:22 +01:00
hitItem : [ ] ,
2025-02-21 12:35:42 +01:00
hitSinceBounce : 0 ,
piercedSinceBounce : 0 ,
// 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 ( ) {
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-02-25 21:23:37 +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 ] ++ ;
if ( upgradeId === 'instant_upgrade' ) {
repeats += 2
}
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)
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" ,
extraLevelsHelp : ` One more life just in case `
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 ,
"help" : "Break many bricks at once."
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-02-23 21:17:22 +01:00
"help" : "Your combo starts at 4" ,
extraLevelsHelp : ` Combo starts 3 points higher `
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-02-23 21:17:22 +01:00
extraLevelsHelp : ` Make it even slower `
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" ,
extraLevelsHelp : ` Even bigger puck `
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-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 ,
"help" : "Avoid the sides for more coins."
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 ,
"help" : "Avoid the top for more coins."
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-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-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-02-23 21:17:22 +01:00
} , extraLevelsHelp : ` Stronger effect on the coins ` ,
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-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-02-23 21:17:22 +01:00
"help" : "Gives you more control" ,
extraLevelsHelp : ` Even smaller puck ` ,
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-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
"color_blind_exclude" : true ,
"max" : 1 ,
2025-02-23 21:17:22 +01:00
"help" : "Break bricks color by color" ,
2025-02-19 21:11:22 +01:00
tryout : {
perks : { picky _eater : 1 } ,
level : 'Mountain'
}
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
"color_blind_exclude" : true ,
"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-02-17 01:07:20 +01:00
} ,
{
2025-02-19 12:37:21 +01:00
"threshold" : 6000 ,
2025-02-17 01:07:20 +01:00
"id" : "catch_all_coins" ,
"giftable" : true ,
"name" : "Compound interest" ,
"max" : 3 ,
2025-02-23 21:17:22 +01:00
"help" : "Avoid missing coins with your puck" ,
extraLevelsHelp : ` Combo grows faster but missed coins hurt it more ` ,
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-02-23 21:17:22 +01:00
"help" : "Clear the level quickly" ,
extraLevelsHelp : ` Combo starts higher but shrinks faster ` ,
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-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-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-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" ,
"color_blind_exclude" : true ,
"max" : 1 ,
2025-02-23 21:17:22 +01:00
"help" : "Ball breaks same color bricks"
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-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-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-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-02-17 01:07:20 +01:00
} ,
2025-02-19 22:06:29 +01:00
{
"threshold" : 35000 ,
"id" : "wind" ,
"name" : "Wind" ,
"max" : 3 ,
2025-02-23 21:17:22 +01:00
"help" : "Puck position creates wind." , extraLevelsHelp : 'Stronger wind force ' ,
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-02-21 12:35:42 +01:00
} ,
{
"threshold" : 45000 ,
"id" : "respawn" ,
"name" : "Respawn" ,
"max" : 4 ,
2025-02-23 21:17:22 +01:00
"help" : "The first brick hit will respawn." , extraLevelsHelp : 'More bricks can respawn ' ,
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 ' ,
} ,
{
"threshold" : 55000 ,
"id" : "instant_upgrade" ,
"name" : "+2 upgrades now" ,
"max" : 2 ,
"help" : "-1 choice permanently" ,
extraLevelsHelp : 'Even fewer options ' ,
} ,
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
. filter ( u => ! ( isSettingOn ( 'color_blind' ) && u . color _blind _exclude ) )
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
console . log ( 'target level not found, will take random one : ' + target )
}
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
. filter ( u => ! ( isSettingOn ( 'color_blind' ) && u . color _blind _exclude ) )
. 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-02-25 21:23:37 +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-02-27 18:56:04 +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-02-27 18:56:04 +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-02-27 18:56:04 +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-02-27 19:11:31 +01:00
if ( isSettingOn ( 'pointerLock' ) ) {
canvas . requestPointerLock ( )
}
2025-02-16 21:21:12 +01:00
}
2025-02-15 19:21:00 +01:00
} ) ;
canvas . addEventListener ( "mousemove" , ( e ) => {
2025-02-27 19:11:31 +01:00
if ( document . pointerLockElement === canvas ) {
setMousePos ( puck + e . movementX ) ;
} else {
2025-02-15 19:21:00 +01:00
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
//this worked great but coins got stuck in corner between bricks, will need to rework it
// if(!currentLevelInfo().squared){
//
// const offsetToLeftSide = (x - offsetX) % brickWidth
// const offsetToTopSide = y % brickWidth
// const bricksMargin=12/(12+120)*brickWidth/2
// if(offsetToLeftSide< bricksMargin || offsetToLeftSide>brickWidth-bricksMargin || offsetToTopSide< bricksMargin || offsetToTopSide>brickWidth-bricksMargin ){return -1}
//
// }
//
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 ) ) ) ;
}
function shouldPierceByColor ( ballOrCoin , vhit , hhit , chit ) {
if ( ! perks . pierce _color ) return false
// if (ballOrCoin.color === 'white') return true
if ( typeof vhit !== 'undefined' && bricks [ vhit ] !== ballOrCoin . color ) {
return false
}
if ( typeof hhit !== 'undefined' && bricks [ hhit ] !== ballOrCoin . color ) {
return false
}
if ( typeof chit !== 'undefined' && bricks [ chit ] !== ballOrCoin . color ) {
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 ++
}
if ( isBall && shouldPierceByColor ( ballOrCoin , vhit , hhit , chit ) ) {
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-02-27 18:56:04 +01:00
if ( keyboardPuckSpeed ) {
setMousePos ( puck + keyboardPuckSpeed )
}
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-02-26 23:03:12 +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 ;
if ( perks . catch _all _coins ) {
decreaseCombo ( coin . points * perks . catch _all _coins , coin . x , canvas . height - coinRadius ) ;
}
}
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-02-26 23:03:12 +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 ;
}
}
}
} ) ;
}
}
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 ,
y : gameZoneHeight ,
2025-02-19 21:11:22 +01:00
color : currentLevelInfo ( ) ? . black _puck ? '#000' : '#FFF' ,
2025-02-17 00:49:03 +01:00
} , perks . puck _repulse _ball , false )
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 ) {
2025-02-26 23:36:08 +01:00
2025-02-23 21:17:22 +01:00
ball . hitItem . slice ( 0 , - 1 ) . slice ( 0 , perks . respawn )
. forEach ( ( { index , color } ) => bricks [ index ] = 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-02-19 22:06:29 +01:00
const loss = resetCombo ( ball . x , ball . y )
if ( ball . bouncesList ? . length ) {
2025-02-15 19:21:00 +01:00
ball . bouncesList . push ( {
x : ball . previousx ,
y : ball . previousy
} )
2025-02-17 00:49:03 +01:00
for ( si = 0 ; si < ball . bouncesList . length - 1 ; si ++ ) {
2025-02-15 19:21:00 +01:00
// segement
2025-02-17 00:49:03 +01:00
const start = ball . bouncesList [ si ]
const end = ball . bouncesList [ si + 1 ]
const distance = distanceBetween ( start , end )
2025-02-16 21:21:12 +01:00
2025-02-17 00:49:03 +01:00
const parts = distance / 30
for ( var i = 0 ; i < parts ; i ++ ) {
2025-02-15 19:21:00 +01:00
flashes . push ( {
type : "particle" ,
duration : 200 ,
ethereal : true ,
time : levelTime ,
size : coinSize / 2 ,
2025-02-19 22:06:29 +01:00
color : loss ? 'red' : ball . color ,
2025-02-17 00:49:03 +01:00
x : start . x + ( i / ( parts - 1 ) ) * ( end . x - start . x ) ,
y : start . y + ( i / ( parts - 1 ) ) * ( end . y - start . y ) ,
2025-02-15 19:21:00 +01:00
vx : ( Math . random ( ) - 0.5 ) * baseSpeed ,
vy : ( Math . random ( ) - 0.5 ) * baseSpeed ,
} ) ;
}
}
}
}
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 ,
color : ball . color ,
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-02-27 23:04:42 +01:00
addToTotalPlayTime ( runStatistics . runTime )
2025-02-23 21:17:22 +01:00
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-02-24 21:04:31 +01:00
function getHistograms ( saveStats ) {
if ( hadOverrides ) { return '' }
let runStats = ''
try {
// Stores only top 100 runs
let runsHistory = JSON . parse ( localStorage . getItem ( 'breakout_71_runs_history' ) || '[]' ) ;
runsHistory . sort ( ( a , b ) => a . score - b . score ) . reverse ( )
2025-02-25 21:23:37 +01:00
runsHistory = runsHistory . slice ( 0 , 10 )
2025-02-24 21:04:31 +01:00
runsHistory . push ( runStatistics )
// Generate some histogram
if ( saveStats ) {
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-02-25 21:23:37 +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-24 21:04:31 +01:00
// One bin per unique value, max 10
const binsCount = Math . min ( values . length , 10 )
if ( binsCount < 3 ) return ''
const bins = [ ]
const binsTotal = [ ]
for ( let i = 0 ; i < binsCount ; i ++ ) {
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 => {
if ( isNaN ( v ) ) return
const index = binIndexOf ( v )
bins [ index ] ++
binsTotal [ index ] += v
} )
if ( bins . filter ( b => b ) . length < 3 ) return ''
const maxBin = Math . max ( ... bins )
const lastValue = values [ values . length - 1 ]
const activeBin = binIndexOf ( lastValue )
return ` <h2 class="histogram-title"> ${ title } : <strong> ${ lastValue } ${ unit } </strong></h2><div class="histogram">
$ { bins . map ( ( v , vi ) => ` <span class=" ${ vi === activeBin ? 'active' : '' } "><span style="height: ${ v / maxBin * 80 } px" title=" ${ v } run ${ v > 1 ? 's' : '' } between ${
Math . floor ( min + vi * binSize ) } and $ { Math . floor ( min + ( vi + 1 ) * binSize ) } $ { unit } "
> < span > $ {
( ! v && ' ' ) || ( vi == activeBin && lastValue + unit ) || ( Math . round ( binsTotal [ vi ] / v ) + unit )
} < / 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 , '' )
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' )
runStats += makeHistogram ( 'Level reached' , r => r . levelsPlayed , '' )
runStats += makeHistogram ( 'Upgrades applied' , r => r . upgrades _picked , '' )
runStats += makeHistogram ( 'Balls lost' , r => r . balls _lost , '' )
runStats += makeHistogram ( 'Average combo' , r => Math . round ( r . coins _spawned / r . bricks _broken ) , '' )
runStats += makeHistogram ( 'Max combo' , r => r . max _combo , '' )
if ( runStats ) {
2025-02-25 21:23:37 +01:00
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 ,
bricks _broken : 0 ,
misses : 0 ,
balls _lost : 0 ,
puck _bounces : 0 ,
upgrades _picked : 1 ,
max _combo : 1
}
}
2025-02-15 19:21:00 +01:00
function explodeBrick ( index , ball , isExplosion ) {
const color = bricks [ index ] ;
2025-02-25 21:23:37 +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 ) ;
const { col , row } = getRowCol ( index ) ;
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-02-23 21:17:22 +01:00
if ( perks . sturdy _bricks && perks . sturdy _bricks * 2 > Math . random ( ) * 10 ) {
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 ] = "" ;
levelSpawnedCoins += combo ;
2025-02-23 21:17:22 +01:00
runStatistics . coins _spawned += combo
runStatistics . bricks _broken ++
2025-02-15 19:21:00 +01:00
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-02-23 21:17:22 +01:00
while ( coinsToSpawn -- ) {
2025-02-15 19:21:00 +01:00
// Avoids saturating the canvas with coins
if ( coins . length > MAX _COINS * ( isSettingOn ( "basic" ) ? 0.5 : 1 ) ) {
// Just pick a random one
coins [ Math . floor ( Math . random ( ) * coins . length ) ] . points ++ ;
continue ;
}
const coord = {
x : x + ( Math . random ( ) - 0.5 ) * ( brickWidth - coinSize ) ,
y : y + ( Math . random ( ) - 0.5 ) * ( brickWidth - coinSize ) ,
} ;
coins . push ( {
points : 1 ,
2025-02-26 23:03:12 +01:00
color : perks . metamorphosis ? color : 'gold' ,
... 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-02-26 23:03:12 +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-02-19 21:11:22 +01:00
combo += Math . max ( 0 , perks . streak _shots + perks . catch _all _coins + perks . sides _are _lava + perks . top _is _lava + perks . picky _eater
- Math . round ( Math . random ( ) * perks . soft _reset ) ) ;
2025-02-15 19:21:00 +01:00
if ( ! isExplosion ) {
// color change
if ( ( perks . picky _eater || perks . pierce _color ) && color !== ball . color ) {
// reset streak
if ( perks . picky _eater ) resetCombo ( ball . x , ball . y ) ;
ball . color = color ;
} 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-02-25 21:23:37 +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
}
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-02-27 19:11:31 +01:00
scoreInfo += score . toString ( ) ;
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 ) => {
drawFuzzyBall ( ctx , ball . color , ballSize * 2 , ball . x , ball . y ) ;
} ) ;
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-02-25 21:23:37 +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 )
} else {
// 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 ) ;
}
} ) ;
}
if ( combo > baseCombo ( ) ) {
2025-02-17 01:07:20 +01:00
// The red should still be visible on a white bg
2025-02-18 16:03:31 +01:00
ctx . globalCompositeOperation = ! level . color && level . svg ? "screen" : 'source-over' ;
2025-02-15 19:21:00 +01:00
ctx . globalAlpha = ( 2 + combo - baseCombo ( ) ) / 50 ;
2025-02-19 12:37:21 +01:00
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 ,
2025-02-19 00:19:40 +01:00
}
2025-02-15 19:21:00 +01:00
if ( perks . top _is _lava ) {
2025-02-19 12:37:21 +01:00
drawRedGradientSquare ( ctx , offsetXRoundedDown , 0 , gameZoneWidthRoundedUp , ballSize , 0 , 0 , 0 , ballSize ) ;
2025-02-19 00:19:40 +01:00
baseParticle && flashes . push ( {
2025-02-19 12:37:21 +01:00
... baseParticle ,
x : offsetXRoundedDown + Math . random ( ) * gameZoneWidthRoundedUp ,
2025-02-19 00:19:40 +01:00
y : 0 ,
vx : ( Math . random ( ) - 0.5 ) * 10 ,
vy : 5 ,
} )
2025-02-15 19:21:00 +01:00
}
if ( perks . sides _are _lava ) {
2025-02-19 12:37:21 +01:00
drawRedGradientSquare ( ctx , offsetXRoundedDown , 0 , ballSize , gameZoneHeight , 0 , 0 , ballSize , 0 , ) ;
drawRedGradientSquare ( ctx , offsetXRoundedDown + gameZoneWidthRoundedUp - ballSize , 0 , ballSize , gameZoneHeight , ballSize , 0 , 0 , 0 , ) ;
const fromLeft = Math . random ( ) > 0.5
2025-02-19 00:19:40 +01:00
baseParticle && flashes . push ( {
2025-02-19 12:37:21 +01:00
... baseParticle ,
x : offsetXRoundedDown + ( fromLeft ? 0 : gameZoneWidthRoundedUp ) ,
y : Math . random ( ) * gameZoneHeight ,
vx : fromLeft ? 5 : - 5 ,
vy : ( Math . random ( ) - 0.5 ) * 10 ,
2025-02-19 00:19:40 +01:00
} )
2025-02-15 19:21:00 +01:00
}
if ( perks . catch _all _coins ) {
2025-02-19 12:37:21 +01:00
drawRedGradientSquare ( ctx , offsetXRoundedDown , gameZoneHeight - ballSize , gameZoneWidthRoundedUp , ballSize , 0 , ballSize , 0 , 0 , ) ;
2025-02-19 00:19:40 +01:00
let x = puck
2025-02-19 12:37:21 +01:00
do {
x = offsetXRoundedDown + gameZoneWidthRoundedUp * Math . random ( )
} while ( Math . abs ( x - puck ) < puckWidth / 2 )
2025-02-19 00:19:40 +01:00
baseParticle && flashes . push ( {
2025-02-19 12:37:21 +01:00
... baseParticle ,
x ,
y : gameZoneHeight ,
vx : ( Math . random ( ) - 0.5 ) * 10 ,
2025-02-19 00:19:40 +01:00
vy : - 5 ,
} )
2025-02-15 19:21:00 +01:00
}
if ( perks . streak _shots ) {
2025-02-19 12:37:21 +01:00
drawRedGradientSquare ( ctx , puck - puckWidth / 2 , gameZoneHeight - puckHeight - ballSize , puckWidth , ballSize , 0 , ballSize , 0 , 0 , ) ;
const pos = ( 0.5 - Math . random ( ) )
2025-02-19 00:19:40 +01:00
baseParticle && flashes . push ( {
2025-02-19 12:37:21 +01:00
... baseParticle ,
duration : 100 ,
x : puck + puckWidth * pos ,
y : gameZoneHeight - puckHeight ,
vx : ( pos ) * 10 ,
2025-02-19 00:19:40 +01:00
vy : - 5 ,
} )
2025-02-15 19:21:00 +01:00
}
if ( perks . picky _eater ) {
let okColors = new Set ( balls . map ( ( b ) => b . color ) ) ;
bricks . forEach ( ( type , index ) => {
if ( ! type || type === "black" || okColors . has ( type ) ) return ;
const x = brickCenterX ( index ) , y = brickCenterY ( index ) ;
drawFuzzyBall ( ctx , "red" , brickWidth , x , y ) ;
} ) ;
}
ctx . globalAlpha = 1 ;
}
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-02-26 23:03:12 +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-02-26 23:03:12 +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-02-27 22:19:50 +01:00
drawBall ( ctx , ball . color , 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" ;
drawPuck ( ctx , puckColor , puckWidth , puckHeight )
if ( combo > 1 ) {
2025-02-17 00:49:03 +01:00
ctx . globalCompositeOperation = "source-over" ;
2025-02-27 22:19:50 +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 )
drawText ( ctx , comboText , ! level . black _puck ? '#000' : '#FFF' , puckHeight ,
puck - comboTextWidth / 2 , gameZoneHeight - puckHeight / 2 , true ) ;
2025-02-15 19:21:00 +01:00
}
// Borders
ctx . fillStyle = puckColor ;
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-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 ,
canvas . width / 2 , gameZoneHeight + ( canvas . height - gameZoneHeight ) / 2 ,
) ;
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 ( ) ;
const newKey = gameZoneWidth + "_" + bricks . join ( "_" ) + bombSVG . complete ;
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
bricks . forEach ( ( color , index ) => {
const x = brickCenterX ( index ) , y = brickCenterY ( index ) ;
if ( ! color ) return ;
drawBrick ( ctx , color , x , y , level . squared || false ) ;
if ( color === 'black' ) {
ctx . globalCompositeOperation = "source-over" ;
drawIMG ( ctx , bombSVG , brickWidth , x , y ) ;
}
} ) ;
}
destinationCtx . drawImage ( cachedBricksRender , offsetX , 0 ) ;
}
let cachedGraphics = { } ;
function drawPuck ( ctx , color , puckWidth , puckHeight ) {
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 ;
}
ctx . drawImage ( cachedGraphics [ key ] , Math . round ( puck - puckWidth / 2 ) , gameZoneHeight - puckHeight * 2 , ) ;
}
2025-02-27 22:19:50 +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-02-27 22:19:50 +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-02-27 22:19:50 +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-02-27 22:19:50 +01:00
if ( borderColor ) {
canctx . lineWidth = 2
canctx . strokeStyle = borderColor
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-02-26 23:03:12 +01:00
function drawCoin ( ctx , color , size , x , y , bg , rawAngle ) {
2025-02-27 22:19:50 +01:00
2025-02-26 23:36:08 +01:00
const angle = ( Math . round ( rawAngle / Math . PI * 2 * 16 ) % 16 + 16 ) % 16
2025-02-26 23:03:12 +01:00
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-02-27 22:19:50 +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 ) ;
canctx . rotate ( angle / 16 ) ;
canctx . translate ( - size / 2 , - size / 2 ) ;
2025-02-26 23:03:12 +01:00
2025-02-27 22:19:50 +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 )
}
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 ;
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 ) , ) ;
}
function drawBrick ( ctx , color , x , y , squared ) {
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 ;
const key = "brick" + color + "_" + width + "_" + height + '_' + squared
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = width ;
can . height = height ;
const canctx = can . getContext ( "2d" ) ;
if ( squared ) {
canctx . fillStyle = color ;
canctx . fillRect ( 0 , 0 , width , height ) ;
} else {
const bord = Math . floor ( brickWidth / 6 ) ;
canctx . strokeStyle = color ;
canctx . lineJoin = "round" ;
canctx . lineWidth = bord * 1.5 ;
canctx . strokeRect ( bord , bord , width - bord * 2 , height - bord * 2 ) ;
canctx . fillStyle = color ;
canctx . fillRect ( bord , bord , width - bord * 2 , height - bord * 2 ) ;
}
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-02-19 12:37:21 +01:00
function drawRedGradientSquare ( ctx , x , y , width , height , redX , redY , blackX , blackY ) {
2025-02-18 16:03:31 +01:00
const key = "gradient" + width + "_" + height + "_" + redX + "_" + redY + "_" + blackX + "_" + blackY ;
2025-02-15 19:21:00 +01:00
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = width ;
can . height = height ;
const canctx = can . getContext ( "2d" ) ;
const gradient = canctx . createLinearGradient ( redX , redY , blackX , blackY ) ;
2025-02-17 01:07:20 +01:00
gradient . addColorStop ( 0 , "rgba(255,0,0,1)" ) ;
gradient . addColorStop ( 1 , "rgba(255,0,0,0)" ) ;
2025-02-15 19:21:00 +01:00
canctx . fillStyle = gradient ;
canctx . fillRect ( 0 , 0 , width , height ) ;
cachedGraphics [ key ] = can ;
}
2025-02-19 00:19:40 +01:00
2025-02-15 19:21:00 +01:00
ctx . drawImage ( cachedGraphics [ key ] , x , y , width , height ) ;
}
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-02-26 23:03:12 +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-02-26 23:03:12 +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-02-26 23:03:12 +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-02-26 23:03:12 +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-02-26 23:03:12 +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-02-26 23:03:12 +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-02-27 18:56:04 +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-02-27 18:56:04 +01:00
closeModal = ( ) => {
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-02-27 18:56:04 +01:00
popup . querySelector ( 'button:not([disabled])' ) ? . focus ( )
} ) . finally ( ( ) => {
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-02-27 18:56:04 +01:00
openScorePanel ( )
} ) ;
async function openScorePanel ( ) {
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: [
{
text : 'Resume' ,
help : "Return to your run" ,
} ,
{
text : "Restart" , help : "Start a brand new run." ,
value : ( ) => {
2025-02-15 19:21:00 +01:00
restart ( ) ;
return true ;
} ,
} ] ,
} ) ;
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
} , "color_blind" : {
default : false , name : ` Color blind mode ` , help : ` Removes mechanics about colors. ` , 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
{
text : 'Resume' ,
help : "Return to your run" ,
async value ( ) {
}
} ,
2025-02-17 00:49:03 +01:00
{
text : 'Unlocks' ,
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 ,
threshold , icon , tryout
} ) => ( {
text : name ,
help : ts >= threshold ? help : ` Unlocks at total score ${ threshold } . ` ,
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 > ` ,
actions
2025-02-17 00:49:03 +01:00
,
allowClose : true ,
} )
if ( tryOn ) {
2025-02-26 19:38:09 +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 : [ {
value : true ,
text : 'Restart game to test item'
} , {
value : false ,
text : 'Cancel'
} ]
} ) )
2025-02-17 00:49:03 +01:00
nextRunOverrides = tryOn
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-02-27 18:56:04 +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-02-27 18:56:04 +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 ;
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-02-24 21:04:31 +01:00
if ( ! recordCanvasCtx ) return
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-02-25 21:23:37 +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
captureStream = recordCanvas . captureStream ( 0 ) ;
if ( isSettingOn ( 'sound' ) && getAudioContext ( ) && audioRecordingTrack ) {
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
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-02-24 21:04:31 +01:00
let targetDiv ;
let blob = new Blob ( recordedChunks , { type : "video/webm" } ) ;
if ( blob . size < 200000 ) return // under 0.2MB, probably bugged out or pointlessly short
while ( ! ( targetDiv = document . getElementById ( "level-recording-container" ) ) ) {
await new Promise ( r => setTimeout ( r , 200 ) )
}
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-02-23 21:17:22 +01:00
2025-02-26 20:44:47 +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-02-27 18:56:04 +01:00
function toggleFullScreen ( ) {
try {
if ( document . fullscreenElement !== null ) {
if ( document . exitFullscreen ) {
document . exitFullscreen ( ) ;
} else if ( document . webkitCancelFullScreen ) {
document . webkitCancelFullScreen ( ) ;
}
} else {
const docel = document . documentElement
if ( docel . requestFullscreen ) {
docel . requestFullscreen ( ) ;
} else if ( docel . webkitRequestFullscreen ) {
docel . webkitRequestFullscreen ( ) ;
}
}
} catch ( e ) {
console . warn ( e )
}
}
const pressed = {
ArrowLeft : 0 ,
ArrowRight : 0 ,
Shift : 0
}
function setKeyPressed ( key , on ) {
pressed [ key ] = on
keyboardPuckSpeed = ( pressed . ArrowRight - pressed . ArrowLeft ) * ( 1 + pressed . Shift * 2 ) * gameZoneWidth / 50
}
document . addEventListener ( 'keydown' , e => {
console . log ( e . key )
if ( e . key . toLowerCase ( ) === 'f' ) {
toggleFullScreen ( )
} else if ( e . key in pressed ) {
setKeyPressed ( e . key , 1 )
} if ( e . key === ' ' && ! alertsOpen ) {
if ( running ) {
pause ( )
} else {
play ( )
}
} else {
return
}
e . preventDefault ( )
} )
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 ( )
2025-02-27 19:11:31 +01:00
} else if ( e . key === 'Escape' && running ) {
pause ( )
2025-02-27 18:56:04 +01:00
} else if ( e . key . toLowerCase ( ) === 'm' && ! alertsOpen ) {
openSettingsPanel ( )
} else if ( e . key . toLowerCase ( ) === 's' && ! alertsOpen ) {
openScorePanel ( )
} else {
return
}
e . preventDefault ( )
} )
2025-02-23 21:17:22 +01:00
2025-02-15 19:21:00 +01:00
fitSize ( )
restart ( )
tick ( ) ;