2025-03-06 14:06:02 +01:00
import { allLevels , appVersion , icons , upgrades } from "./loadGameData" ;
import { PerkId } from "./types" ;
2025-03-05 22:10:17 +01:00
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-03-06 14:06:02 +01:00
const canvas = document . getElementById ( "game" ) as HTMLCanvasElement ;
2025-02-15 19:21:00 +01:00
let ctx = canvas . getContext ( "2d" , { alpha : false } ) ;
let ballSize = 20 ;
const coinSize = Math . round ( ballSize * 0.8 ) ;
const puckHeight = ballSize ;
2025-02-25 14:36:07 +01:00
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" / >
< / svg > ` );
// Whatever
let puckWidth = 200 ;
2025-03-06 14:06:02 +01:00
const perks = { } as { [ id in PerkId ] : number } ;
2025-02-15 19:21:00 +01:00
let baseSpeed = 12 ; // applied to x and y
let combo = 1 ;
function baseCombo() {
2025-03-01 14:20:27 +01:00
return 1 + perks . base_combo * 3 + perks . smaller_puck * 5 ;
2025-02-15 19:21:00 +01:00
}
2025-03-06 14:06:02 +01:00
function resetCombo ( x : number | undefined , y : number | undefined ) {
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 ,
2025-03-03 22:14:28 +01:00
color : "r" ,
2025-02-15 19:21:00 +01:00
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
}
2025-03-06 14:06:02 +01:00
function decreaseCombo ( by : number , x : number , y : number ) {
2025-02-15 19:21:00 +01:00
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 ,
2025-03-03 22:14:28 +01:00
color : "r" ,
2025-02-15 19:21:00 +01:00
x : x ,
y : y ,
duration : 300 ,
size : puckHeight ,
} ) ;
}
}
}
let gridSize = 12 ;
2025-03-06 14:06:02 +01:00
let running = false , puck = 400 , pauseTimeout : number | null = null ;
2025-02-17 00:49:03 +01:00
function play() {
if ( running ) return
2025-02-16 21:21:12 +01:00
running = true
2025-02-17 00:49:03 +01:00
if ( audioContext ) {
2025-02-16 21:21:12 +01:00
audioContext . resume ( )
}
2025-02-20 11:34:11 +01:00
resumeRecording ( )
2025-02-16 21:21:12 +01:00
}
2025-02-17 00:49:03 +01:00
2025-02-27 18:56:04 +01:00
function pause ( playerAskedForPause ) {
2025-02-17 00:49:03 +01:00
if ( ! running ) return
2025-02-27 18:56:04 +01:00
if ( pauseTimeout ) return
2025-03-01 14:20:27 +01:00
pauseTimeout = setTimeout ( ( ) = > {
2025-02-27 18:56:04 +01:00
running = false
needsRender = true
if ( audioContext ) {
setTimeout ( ( ) = > {
2025-03-05 16:21:34 +01:00
if ( ! running ) audioContext . suspend ( )
2025-02-27 18:56:04 +01:00
} , 1000 )
}
pauseRecording ( )
2025-03-01 14:20:27 +01:00
pauseTimeout = null
} , Math . min ( Math . max ( 0 , pauseUsesDuringRun - 5 ) * 50 , 500 ) )
2025-02-27 18:56:04 +01:00
2025-03-01 14:20:27 +01:00
if ( playerAskedForPause ) {
2025-02-27 18:56:04 +01:00
// Pausing many times in a run will make pause slower
pauseUsesDuringRun ++
2025-02-16 21:21:12 +01:00
}
2025-02-27 18:56:04 +01:00
2025-03-01 14:20:27 +01:00
if ( document . exitPointerLock ) {
2025-02-27 19:11:31 +01:00
document . exitPointerLock ( )
}
2025-02-16 21:21:12 +01:00
}
2025-03-06 14:06:02 +01:00
let offsetX : number , offsetXRoundedDown : number , gameZoneWidth : number , gameZoneWidthRoundedUp : number ,
gameZoneHeight : number , brickWidth : number , 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 ) ;
2025-03-01 21:59:41 +01:00
window . addEventListener ( "fullscreenchange" , fitSize ) ;
2025-02-15 19:21:00 +01:00
function recomputeTargetBaseSpeed() {
2025-02-19 12:37:21 +01:00
// We never want the ball to completely stop, it will move at least 3px per frame
2025-02-19 21:11:22 +01:00
baseSpeed = Math . max ( 3 , gameZoneWidth / 12 / 10 + currentLevel / 3 + levelTime / ( 30 * 1000 ) - perks . slow_down * 2 ) ;
2025-02-15 19:21:00 +01:00
}
function brickCenterX ( index ) {
return offsetX + ( ( index % gridSize ) + 0.5 ) * brickWidth ;
}
function brickCenterY ( index ) {
return ( Math . floor ( index / gridSize ) + 0.5 ) * brickWidth ;
}
function getRowColIndex ( row , col ) {
if ( row < 0 || col < 0 || row >= gridSize || col >= gridSize ) return - 1 ;
return row * gridSize + col ;
}
2025-02-26 23:36:08 +01:00
2025-02-15 19:21:00 +01:00
function spawnExplosion ( count , x , y , color , duration = 150 , size = coinSize ) {
if ( ! ! isSettingOn ( "basic" ) ) return ;
2025-03-01 14:20:27 +01:00
if ( flashes . length > MAX_PARTICLES ) {
2025-02-25 21:23:37 +01:00
// Avoid freezing when lots of explosion happen at once
count = 1
}
2025-02-15 19:21:00 +01:00
for ( let i = 0 ; i < count ; i ++ ) {
flashes . push ( {
type : "particle" ,
time : levelTime ,
size ,
x : x + ( ( Math . random ( ) - 0.5 ) * brickWidth ) / 2 ,
y : y + ( ( Math . random ( ) - 0.5 ) * brickWidth ) / 2 ,
vx : ( Math . random ( ) - 0.5 ) * 30 ,
vy : ( Math . random ( ) - 0.5 ) * 30 ,
2025-02-19 12:37:21 +01:00
color ,
2025-02-19 21:11:22 +01:00
duration : 150
2025-02-15 19:21:00 +01:00
} ) ;
}
}
let score = 0 ;
let lastexplosion = 0 ;
let highScore = parseFloat ( localStorage . getItem ( "breakout-3-hs" ) || "0" ) ;
let lastPlayedCoinGrab = 0
function addToScore ( coin ) {
coin . destroyed = true
score += coin . points ;
addToTotalScore ( coin . points )
2025-03-06 14:06:02 +01:00
if ( score > highScore ) {
2025-02-15 19:21:00 +01:00
highScore = score ;
2025-03-06 14:06:02 +01:00
localStorage . setItem ( "breakout-3-hs" , score . toString ( ) ) ;
2025-02-15 19:21:00 +01:00
}
if ( ! isSettingOn ( 'basic' ) ) {
flashes . push ( {
type : "particle" ,
duration : 100 + Math . random ( ) * 50 ,
time : levelTime ,
size : coinSize / 2 ,
color : coin.color ,
x : coin.previousx ,
y : coin.previousy ,
vx : ( canvas . width - coin . x ) / 100 ,
vy : - coin . y / 100 ,
ethereal : true ,
} )
}
if ( Date . now ( ) - lastPlayedCoinGrab > 16 ) {
lastPlayedCoinGrab = Date . now ( )
sounds . coinCatch ( coin . x )
}
2025-03-01 14:20:27 +01:00
runStatistics . score += coin . points
2025-02-23 21:17:22 +01:00
2025-02-15 19:21:00 +01:00
}
let balls = [ ] ;
2025-03-01 20:40:20 +01:00
let ballsColor = 'white'
2025-02-15 19:21:00 +01:00
function resetBalls() {
const count = 1 + ( perks ? . multiball || 0 ) ;
const perBall = puckWidth / ( count + 1 ) ;
balls = [ ] ;
2025-03-06 14:06:02 +01:00
ballsColor = "#FFF"
2025-02-15 19:21:00 +01:00
for ( let i = 0 ; i < count ; i ++ ) {
const x = puck - puckWidth / 2 + perBall * ( i + 1 ) ;
balls . push ( {
x ,
previousx : x ,
y : gameZoneHeight - 1.5 * ballSize ,
previousy : gameZoneHeight - 1.5 * ballSize ,
vx : Math.random ( ) > 0.5 ? baseSpeed : - baseSpeed ,
vy : - baseSpeed ,
sx : 0 ,
sy : 0 ,
sparks : 0 ,
2025-02-21 12:35:42 +01:00
piercedSinceBounce : 0 ,
hitSinceBounce : 0 ,
2025-02-23 21:17:22 +01:00
hitItem : [ ] ,
sapperUses : 0 ,
2025-02-15 19:21:00 +01:00
} ) ;
}
}
function putBallsAtPuck() {
2025-02-21 12:35:42 +01:00
// This reset could be abused to cheat quite easily
2025-02-15 19:21:00 +01:00
const count = balls . length ;
const perBall = puckWidth / ( count + 1 ) ;
balls . forEach ( ( ball , i ) = > {
const x = puck - puckWidth / 2 + perBall * ( i + 1 ) ;
2025-03-01 20:40:20 +01:00
ball . x = x
ball . previousx = x
ball . y = gameZoneHeight - 1.5 * ballSize
ball . previousy = ball . y
ball . vx = Math . random ( ) > 0.5 ? baseSpeed : - baseSpeed
ball . vy = - baseSpeed
ball . sx = 0
ball . sy = 0
ball . hitItem = [ ]
ball . hitSinceBounce = 0
ball . piercedSinceBounce = 0
2025-02-15 19:21:00 +01:00
} ) ;
}
resetBalls ( ) ;
// Default, recomputed at each level load
let bricks = [ ] ;
let flashes = [ ] ;
let coins = [ ] ;
let levelStartScore = 0 ;
let levelMisses = 0 ;
let levelSpawnedCoins = 0 ;
2025-02-24 21:04:31 +01:00
function pickedUpgradesHTMl() {
let list = ''
for ( let u of upgrades ) {
2025-03-06 14:06:02 +01:00
for ( let i = 0 ; i < perks [ u . id ] ; i ++ ) list += icons [ 'icon:' + u . id ] + ' '
2025-02-24 21:04:31 +01:00
}
return list
}
async function openUpgradesPicker() {
2025-03-01 14:20:27 +01:00
const catchRate = ( score - levelStartScore ) / ( levelSpawnedCoins || 1 ) ;
2025-02-19 12:37:21 +01:00
2025-02-15 19:21:00 +01:00
let repeats = 1 ;
let choices = 3 ;
2025-02-19 12:37:21 +01:00
let timeGain = '' , catchGain = '' , missesGain = ''
2025-02-15 19:21:00 +01:00
if ( levelTime < 30 * 1000 ) {
repeats ++ ;
choices ++ ;
2025-02-25 21:23:37 +01:00
timeGain = " (+1 upgrade and choice)"
2025-02-15 19:21:00 +01:00
} else if ( levelTime < 60 * 1000 ) {
choices ++ ;
2025-02-19 12:37:21 +01:00
timeGain = " (+1 choice)"
2025-02-15 19:21:00 +01:00
}
if ( catchRate === 1 ) {
repeats ++ ;
choices ++ ;
2025-02-25 21:23:37 +01:00
catchGain = " (+1 upgrade and choice)"
2025-02-15 19:21:00 +01:00
} else if ( catchRate > 0.9 ) {
choices ++ ;
2025-02-19 12:37:21 +01:00
catchGain = " (+1 choice)"
2025-02-15 19:21:00 +01:00
}
if ( levelMisses === 0 ) {
repeats ++ ;
choices ++ ;
2025-02-25 21:23:37 +01:00
missesGain = " (+1 upgrade and choice)"
2025-02-15 19:21:00 +01:00
} else if ( levelMisses <= 3 ) {
choices ++ ;
2025-02-19 12:37:21 +01:00
missesGain = " (+1 choice)"
2025-02-15 19:21:00 +01:00
}
2025-02-19 12:37:21 +01:00
2025-02-15 19:21:00 +01:00
while ( repeats -- ) {
2025-03-01 14:20:27 +01:00
const actions = pickRandomUpgrades ( choices + perks . one_more_choice - perks . instant_upgrade ) ;
2025-02-15 19:21:00 +01:00
if ( ! actions . length ) break
2025-02-24 21:04:31 +01:00
let textAfterButtons = `
2025-03-05 14:43:58 +01:00
< p > You just finished level $ { currentLevel + 1 } / $ { max_levels ( ) } and picked those upgrades so far : < / p > < p > $ { pickedUpgradesHTMl ( ) } < / p >
< div id = "level-recording-container" > < / div >
` ;
2025-02-19 12:37:21 +01:00
2025-02-25 21:23:37 +01:00
const upgradeId = await asyncAlert ( {
2025-03-05 16:21:34 +01:00
title : "Pick an upgrade " + ( repeats ? "(" + ( repeats + 1 ) + ")" : "" ) , actions , text : ` <p>
2025-03-05 14:43:58 +01:00
You caught $ { score - levelStartScore } coins $ { catchGain } out of $ { levelSpawnedCoins } in $ { Math . round ( levelTime / 1000 ) } seconds $ { timeGain } .
You missed $ { levelMisses } times $ { missesGain } .
$ { ( ( timeGain && catchGain && missesGain ) && 'Impressive, keep it up !' ) || ( ( timeGain || catchGain || missesGain ) && 'Well done !' ) || 'Try to catch all coins, never miss the bricks or clear the level under 30s to gain additional choices and upgrades.' }
2025-03-05 16:21:34 +01:00
< / p > ` , allowClose: false, textAfterButtons
2025-02-15 19:21:00 +01:00
} ) ;
2025-02-25 21:23:37 +01:00
perks [ upgradeId ] ++ ;
2025-03-01 14:20:27 +01:00
if ( upgradeId === 'instant_upgrade' ) {
repeats += 2
2025-02-25 21:23:37 +01:00
}
2025-02-23 21:17:22 +01:00
runStatistics . upgrades_picked ++
2025-02-15 19:21:00 +01:00
}
2025-03-06 14:06:02 +01:00
resetCombo ( undefined , undefined ) ;
2025-02-15 19:21:00 +01:00
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
2025-03-06 14:06:02 +01:00
resetCombo ( undefined , undefined ) ;
2025-02-15 19:21:00 +01:00
recomputeTargetBaseSpeed ( ) ;
resetBalls ( ) ;
const lvl = currentLevelInfo ( ) ;
if ( lvl . size !== gridSize ) {
gridSize = lvl . size ;
fitSize ( ) ;
}
coins = [ ] ;
bricks = [ . . . lvl . bricks ] ;
flashes = [ ] ;
2025-02-25 21:23:37 +01:00
// This caused problems with accented characters like the ô of côte d'ivoire for odd reasons
// background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg)
2025-03-01 14:20:27 +01:00
background . src = 'data:image/svg+xml;UTF8,' + lvl . svg
2025-02-20 11:34:11 +01:00
stopRecording ( )
startRecordingGame ( )
2025-02-15 19:21:00 +01:00
}
function currentLevelInfo() {
return runLevels [ currentLevel % runLevels . length ] ;
}
function reset_perks() {
for ( let u of upgrades ) {
perks [ u . id ] = 0 ;
}
2025-02-17 00:49:03 +01:00
if ( nextRunOverrides . perks ) {
const first = Object . keys ( nextRunOverrides . perks ) [ 0 ]
Object . assign ( perks , nextRunOverrides . perks )
nextRunOverrides . perks = null
return first
2025-02-15 19:21:00 +01:00
}
2025-02-17 01:07:20 +01:00
const giftable = getPossibleUpgrades ( ) . filter ( u = > u . giftable )
2025-02-15 19:21:00 +01:00
const randomGift = isSettingOn ( 'easy' ) ? 'slow_down' : giftable [ Math . floor ( Math . random ( ) * giftable . length ) ] . id ;
perks [ randomGift ] = 1 ;
2025-02-16 21:21:12 +01:00
2025-02-15 19:21:00 +01:00
return randomGift
}
2025-02-19 12:37:21 +01:00
let totalScoreAtRunStart = getTotalScore ( )
2025-02-15 19:21:00 +01:00
function getPossibleUpgrades() {
return upgrades
2025-02-19 00:19:40 +01:00
. filter ( u = > totalScoreAtRunStart >= u . threshold )
2025-03-06 14:06:02 +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 ;
2025-03-05 19:50:17 +01:00
const firstLevel = nextRunOverrides ? . level ?
allLevels . filter ( l = > l . name === target ) : [ ]
2025-02-19 12:37:21 +01:00
2025-03-06 14:06:02 +01:00
const restInRandomOrder = allLevels
2025-02-19 00:19:40 +01:00
. filter ( ( l , li ) = > totalScoreAtRunStart >= l . threshold )
2025-03-06 14:06:02 +01:00
. filter ( l = > l . name !== nextRunOverrides ? . level )
2025-02-15 19:21:00 +01:00
. filter ( l = > l . name !== nameToAvoid || allLevels . length === 1 )
. sort ( ( ) = > Math . random ( ) - 0.5 )
2025-03-05 19:50:17 +01:00
runLevels = firstLevel . concat (
restInRandomOrder . slice ( 0 , 7 + 3 )
2025-03-06 14:06:02 +01:00
. sort ( ( a , b ) = > a . sortKey - b . sortKey )
2025-03-05 19:50:17 +01:00
)
2025-02-17 00:49:03 +01:00
2025-02-15 19:21:00 +01:00
}
function getUpgraderUnlockPoints() {
let list = [ ]
upgrades
. forEach ( u = > {
2025-02-17 01:07:20 +01:00
if ( u . threshold ) {
2025-02-15 19:21:00 +01:00
list . push ( {
2025-03-05 16:21:34 +01:00
threshold : u.threshold , title : u.name + ' (Perk)'
2025-02-15 19:21:00 +01:00
} )
}
} )
allLevels . forEach ( ( l , li ) = > {
list . push ( {
2025-03-05 16:21:34 +01:00
threshold : l.threshold , 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-03-06 14:06:02 +01:00
icon : icons [ 'icon:' + u . id ] ,
2025-03-01 14:20:27 +01:00
value : u.id ,
2025-03-05 16:21:34 +01:00
help : u.help ( perks [ u . id ] + 1 ) , // max: u.max,
2025-02-19 12:37:21 +01:00
// checked: perks[u.id]
} ) )
2025-02-15 19:21:00 +01:00
}
2025-02-17 00:49:03 +01:00
let nextRunOverrides = { level : null , perks : null }
2025-03-05 19:50:17 +01:00
let pauseUsesDuringRun = 0
2025-02-15 19:21:00 +01:00
function restart() {
// When restarting, we want to avoid restarting with the same level we're on, so we exclude from the next
// run's level list
2025-02-19 12:37:21 +01:00
totalScoreAtRunStart = getTotalScore ( )
2025-02-15 19:21:00 +01:00
shuffleLevels ( levelTime || score ? currentLevelInfo ( ) . name : null ) ;
resetRunStatistics ( )
score = 0 ;
2025-03-01 14:20:27 +01:00
pauseUsesDuringRun = 0
2025-02-24 21:04:31 +01:00
2025-02-15 19:21:00 +01:00
const randomGift = reset_perks ( ) ;
2025-02-19 22:06:29 +01:00
dontOfferTooSoon ( randomGift )
2025-02-15 19:21:00 +01:00
setLevel ( 0 ) ;
2025-02-20 11:34:11 +01:00
pauseRecording ( )
2025-02-15 19:21:00 +01:00
}
2025-03-01 14:20:27 +01:00
let keyboardPuckSpeed = 0
2025-02-15 19:21:00 +01:00
function setMousePos ( x ) {
needsRender = true ;
puck = x ;
2025-02-18 16:03:31 +01:00
// We have borders visible, enforce them
if ( puck < offsetXRoundedDown + puckWidth / 2 ) {
puck = offsetXRoundedDown + puckWidth / 2 ;
}
if ( puck > offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2 ) {
puck = offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2 ;
2025-02-15 19:21:00 +01:00
}
if ( ! running && ! levelTime ) {
putBallsAtPuck ( ) ;
}
}
canvas . addEventListener ( "mouseup" , ( e ) = > {
if ( e . button !== 0 ) return ;
2025-02-17 00:49:03 +01:00
if ( running ) {
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-17 00:49:03 +01:00
} else {
2025-02-16 21:21:12 +01:00
play ( )
2025-03-01 14:20:27 +01:00
if ( isSettingOn ( 'pointerLock' ) ) {
2025-02-27 19:11:31 +01:00
canvas . requestPointerLock ( )
}
2025-02-16 21:21:12 +01:00
}
2025-02-15 19:21:00 +01:00
} ) ;
canvas . addEventListener ( "mousemove" , ( e ) = > {
2025-03-01 14:20:27 +01:00
if ( document . pointerLockElement === canvas ) {
setMousePos ( puck + e . movementX ) ;
} else {
setMousePos ( e . x ) ;
2025-02-27 19:11:31 +01:00
}
2025-02-15 19:21:00 +01:00
} ) ;
canvas . addEventListener ( "touchstart" , ( e ) = > {
e . preventDefault ( ) ;
if ( ! e . touches ? . length ) return ;
setMousePos ( e . touches [ 0 ] . pageX ) ;
2025-02-16 21:21:12 +01:00
play ( )
2025-02-15 19:21:00 +01:00
} ) ;
canvas . addEventListener ( "touchend" , ( e ) = > {
e . preventDefault ( ) ;
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-15 19:21:00 +01:00
} ) ;
canvas . addEventListener ( "touchcancel" , ( e ) = > {
e . preventDefault ( ) ;
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-15 19:21:00 +01:00
needsRender = true
} ) ;
canvas . addEventListener ( "touchmove" , ( e ) = > {
if ( ! e . touches ? . length ) return ;
setMousePos ( e . touches [ 0 ] . pageX ) ;
} ) ;
let lastTick = performance . now ( ) ;
function brickIndex ( x , y ) {
2025-02-26 23:03:12 +01:00
return getRowColIndex ( Math . floor ( y / brickWidth ) , Math . floor ( ( x - offsetX ) / brickWidth ) )
2025-02-15 19:21:00 +01:00
}
function hasBrick ( index ) {
if ( bricks [ index ] ) return index ;
}
function hitsSomething ( x , y , radius ) {
return ( hasBrick ( brickIndex ( x - radius , y - radius ) ) ? ? hasBrick ( brickIndex ( x + radius , y - radius ) ) ? ? hasBrick ( brickIndex ( x + radius , y + radius ) ) ? ? hasBrick ( brickIndex ( x - radius , y + radius ) ) ) ;
}
2025-03-02 09:14:39 +01:00
function shouldPierceByColor ( vhit , hhit , chit ) {
2025-02-15 19:21:00 +01:00
if ( ! perks . pierce_color ) return false
2025-03-01 20:40:20 +01:00
if ( typeof vhit !== 'undefined' && bricks [ vhit ] !== ballsColor ) {
2025-02-15 19:21:00 +01:00
return false
}
2025-03-01 20:40:20 +01:00
if ( typeof hhit !== 'undefined' && bricks [ hhit ] !== ballsColor ) {
2025-02-15 19:21:00 +01:00
return false
}
2025-03-01 20:40:20 +01:00
if ( typeof chit !== 'undefined' && bricks [ chit ] !== ballsColor ) {
2025-02-15 19:21:00 +01:00
return false
}
return true
}
function brickHitCheck ( ballOrCoin , radius , isBall ) {
// Make ball/coin bonce, and return bricks that were hit
2025-02-17 17:52:20 +01:00
const { x , y , previousx , previousy } = ballOrCoin ;
2025-02-15 19:21:00 +01:00
const vhit = hitsSomething ( previousx , y , radius ) ;
const hhit = hitsSomething ( x , previousy , radius ) ;
const chit = ( typeof vhit == "undefined" && typeof hhit == "undefined" && hitsSomething ( x , y , radius ) ) || undefined ;
let pierce = isBall && ballOrCoin . piercedSinceBounce < perks . pierce * 3 ;
if ( pierce && ( typeof vhit !== "undefined" || typeof hhit !== "undefined" || typeof chit !== "undefined" ) ) {
ballOrCoin . piercedSinceBounce ++
}
2025-03-02 09:14:39 +01:00
if ( isBall && shouldPierceByColor ( vhit , hhit , chit ) ) {
2025-02-15 19:21:00 +01:00
pierce = true
}
if ( typeof vhit !== "undefined" || typeof chit !== "undefined" ) {
if ( ! pierce ) {
ballOrCoin . y = ballOrCoin . previousy ;
ballOrCoin . vy *= - 1 ;
}
if ( ! isBall ) {
// Roll on corners
const leftHit = bricks [ brickIndex ( x - radius , y + radius ) ] ;
const rightHit = bricks [ brickIndex ( x + radius , y + radius ) ] ;
if ( leftHit && ! rightHit ) {
ballOrCoin . vx += 1 ;
}
if ( ! leftHit && rightHit ) {
ballOrCoin . vx -= 1 ;
}
}
}
if ( typeof hhit !== "undefined" || typeof chit !== "undefined" ) {
if ( ! pierce ) {
ballOrCoin . x = ballOrCoin . previousx ;
ballOrCoin . vx *= - 1 ;
}
}
return vhit ? ? hhit ? ? chit ;
}
function bordersHitCheck ( coin , radius , delta ) {
if ( coin . destroyed ) return ;
coin . previousx = coin . x ;
coin . previousy = coin . y ;
coin . x += coin . vx * delta ;
coin . y += coin . vy * delta ;
coin . sx || = 0 ;
coin . sy || = 0 ;
coin . sx += coin . previousx - coin . x ;
coin . sy += coin . previousy - coin . y ;
coin . sx *= 0.9 ;
coin . sy *= 0.9 ;
2025-02-19 22:06:29 +01:00
if ( perks . wind ) {
coin . vx += ( puck - ( offsetX + gameZoneWidth / 2 ) ) / gameZoneWidth * perks . wind * 0.5 ;
}
2025-02-15 19:21:00 +01:00
let vhit = 0 , hhit = 0 ;
2025-02-18 16:03:31 +01:00
if ( coin . x < offsetXRoundedDown + radius ) {
coin . x = offsetXRoundedDown + radius ;
2025-02-15 19:21:00 +01:00
coin . vx *= - 1 ;
hhit = 1 ;
}
if ( coin . y < radius ) {
coin . y = radius ;
coin . vy *= - 1 ;
vhit = 1 ;
}
2025-02-18 16:03:31 +01:00
if ( coin . x > canvas . width - offsetXRoundedDown - radius ) {
coin . x = canvas . width - offsetXRoundedDown - radius ;
2025-02-15 19:21:00 +01:00
coin . vx *= - 1 ;
hhit = 1 ;
}
return hhit + vhit * 2 ;
}
let lastTickDown = 0 ;
2025-02-27 18:56:04 +01:00
2025-02-15 19:21:00 +01:00
function tick() {
recomputeTargetBaseSpeed ( ) ;
const currentTick = performance . now ( ) ;
puckWidth = ( gameZoneWidth / 12 ) * ( 3 - perks . smaller_puck + perks . bigger_puck ) ;
2025-03-01 14:20:27 +01:00
if ( keyboardPuckSpeed ) {
setMousePos ( puck + keyboardPuckSpeed )
2025-02-27 18:56:04 +01:00
}
2025-03-01 14:20:27 +01:00
if ( running ) {
2025-02-15 19:21:00 +01:00
levelTime += currentTick - lastTick ;
2025-02-23 21:17:22 +01:00
runStatistics . runTime += currentTick - lastTick
runStatistics . max_combo = Math . max ( runStatistics . max_combo , combo )
2025-02-27 18:56:04 +01:00
// How many times to compute
2025-02-15 19:21:00 +01:00
let delta = Math . min ( 4 , ( currentTick - lastTick ) / ( 1000 / 60 ) ) ;
delta *= running ? 1 : 0
coins = coins . filter ( ( coin ) = > ! coin . destroyed ) ;
balls = balls . filter ( ( ball ) = > ! ball . destroyed ) ;
const remainingBricks = bricks . filter ( ( b ) = > b && b !== "black" ) . length ;
if ( levelTime > lastTickDown + 1000 && perks . hot_start ) {
lastTickDown = levelTime ;
decreaseCombo ( perks . hot_start , puck , gameZoneHeight - 2 * puckHeight ) ;
}
2025-02-26 19:38:09 +01:00
if ( remainingBricks <= perks . skip_last ) {
2025-02-15 19:21:00 +01:00
bricks . forEach ( ( type , index ) = > {
if ( type ) {
explodeBrick ( index , balls [ 0 ] , true ) ;
}
} ) ;
}
if ( ! remainingBricks && ! coins . length ) {
if ( currentLevel + 1 < max_levels ( ) ) {
setLevel ( currentLevel + 1 ) ;
} else {
gameOver ( "Run finished with " + score + " points" , "You cleared all levels for this run." ) ;
}
} else if ( running || levelTime ) {
let playedCoinBounce = false ;
const coinRadius = Math . round ( coinSize / 2 ) ;
2025-02-17 00:49:03 +01:00
2025-02-15 19:21:00 +01:00
coins . forEach ( ( coin ) = > {
if ( coin . destroyed ) return ;
if ( perks . coin_magnet ) {
coin . vx += ( ( delta * ( puck - coin . x ) ) / ( 100 + Math . pow ( coin . y - gameZoneHeight , 2 ) + Math . pow ( coin . x - puck , 2 ) ) ) * perks . coin_magnet * 100 ;
}
const ratio = 1 - ( perks . viscosity * 0.03 + 0.005 ) * delta ;
coin . vy *= ratio ;
coin . vx *= ratio ;
2025-03-01 14:36:44 +01:00
if ( coin . vx > 7 * baseSpeed ) coin . vx = 7 * baseSpeed
if ( coin . vx < - 7 * baseSpeed ) coin . vx = - 7 * baseSpeed
if ( coin . vy > 7 * baseSpeed ) coin . vy = 7 * baseSpeed
if ( coin . vy < - 7 * baseSpeed ) coin . vy = - 7 * baseSpeed
2025-03-01 14:20:27 +01:00
coin . a += coin . sa
2025-02-15 19:21:00 +01:00
// Gravity
coin . vy += delta * coin . weight * 0.8 ;
const speed = Math . abs ( coin . sx ) + Math . abs ( coin . sx ) ;
const hitBorder = bordersHitCheck ( coin , coinRadius , delta ) ;
if ( coin . y > gameZoneHeight - coinRadius - puckHeight && coin . y < gameZoneHeight + puckHeight + coin . vy && Math . abs ( coin . x - puck ) < coinRadius + puckWidth / 2 + // a bit of margin to be nice
puckHeight ) {
addToScore ( coin ) ;
} else if ( coin . y > canvas . height + coinRadius ) {
coin . destroyed = true ;
2025-03-01 20:40:20 +01:00
if ( perks . compound_interest ) {
decreaseCombo ( coin . points * perks . compound_interest , coin . x , canvas . height - coinRadius ) ;
2025-02-15 19:21:00 +01:00
}
}
const hitBrick = brickHitCheck ( coin , coinRadius , false ) ;
if ( perks . metamorphosis && typeof hitBrick !== "undefined" ) {
if ( bricks [ hitBrick ] && coin . color !== bricks [ hitBrick ] && bricks [ hitBrick ] !== "black" && ! coin . coloredABrick ) {
bricks [ hitBrick ] = coin . color ;
coin . coloredABrick = true
}
}
if ( typeof hitBrick !== "undefined" || hitBorder ) {
coin . vx *= 0.8 ;
coin . vy *= 0.8 ;
2025-03-01 14:20:27 +01:00
coin . sa *= 0.9
2025-02-15 19:21:00 +01:00
if ( speed > 20 && ! playedCoinBounce ) {
playedCoinBounce = true ;
sounds . coinBounce ( coin . x , 0.2 ) ;
}
if ( Math . abs ( coin . vy ) < 3 ) {
coin . vy = 0 ;
}
}
} ) ;
balls . forEach ( ( ball ) = > ballTick ( ball , delta ) ) ;
2025-02-19 22:06:29 +01:00
if ( perks . wind ) {
const windD = ( puck - ( offsetX + gameZoneWidth / 2 ) ) / gameZoneWidth * 2 * perks . wind
for ( var i = 0 ; i < perks . wind ; i ++ ) {
2025-02-20 11:34:11 +01:00
if ( Math . random ( ) * Math . abs ( windD ) > 0.5 ) {
2025-02-19 22:06:29 +01:00
flashes . push ( {
type : "particle" ,
duration : 150 ,
ethereal : true ,
time : levelTime ,
size : coinSize / 2 ,
color : rainbowColor ( ) ,
2025-02-20 11:34:11 +01:00
x : offsetXRoundedDown + Math . random ( ) * gameZoneWidthRoundedUp ,
2025-02-19 22:06:29 +01:00
y : Math.random ( ) * gameZoneHeight ,
2025-02-20 11:34:11 +01:00
vx : windD * 8 ,
2025-02-19 22:06:29 +01:00
vy : 0 ,
} ) ;
}
}
}
2025-02-15 19:21:00 +01:00
flashes . forEach ( ( flash ) = > {
if ( flash . type === "particle" ) {
flash . x += flash . vx * delta ;
flash . y += flash . vy * delta ;
if ( ! flash . ethereal ) {
flash . vy += 0.5 ;
if ( hasBrick ( brickIndex ( flash . x , flash . y ) ) ) {
flash . destroyed = true ;
}
}
}
} ) ;
}
2025-03-01 14:20:27 +01:00
if ( combo > baseCombo ( ) ) {
// The red should still be visible on a white bg
const baseParticle = ! isSettingOn ( 'basic' ) && ( combo - baseCombo ( ) ) * Math . random ( ) > 5 && running && {
type : "particle" ,
duration : 100 * ( Math . random ( ) + 1 ) ,
time : levelTime ,
size : coinSize / 2 ,
color : 'red' ,
ethereal : true ,
}
if ( perks . top_is_lava ) {
baseParticle && flashes . push ( {
. . . baseParticle ,
x : offsetXRoundedDown + Math . random ( ) * gameZoneWidthRoundedUp ,
y : 0 ,
vx : ( Math . random ( ) - 0.5 ) * 10 ,
vy : 5 ,
} )
}
if ( perks . sides_are_lava ) {
const fromLeft = Math . random ( ) > 0.5
baseParticle && flashes . push ( {
. . . baseParticle ,
x : offsetXRoundedDown + ( fromLeft ? 0 : gameZoneWidthRoundedUp ) ,
y : Math.random ( ) * gameZoneHeight ,
vx : fromLeft ? 5 : - 5 ,
vy : ( Math . random ( ) - 0.5 ) * 10 ,
} )
}
2025-03-01 20:40:20 +01:00
if ( perks . compound_interest ) {
2025-03-01 14:20:27 +01:00
let x = puck
do {
x = offsetXRoundedDown + gameZoneWidthRoundedUp * Math . random ( )
} while ( Math . abs ( x - puck ) < puckWidth / 2 )
baseParticle && flashes . push ( {
2025-03-05 16:21:34 +01:00
. . . baseParticle , x , y : gameZoneHeight , vx : ( Math . random ( ) - 0.5 ) * 10 , vy : - 5 ,
2025-03-01 14:20:27 +01:00
} )
}
if ( perks . streak_shots ) {
const pos = ( 0.5 - Math . random ( ) )
baseParticle && flashes . push ( {
. . . baseParticle ,
duration : 100 ,
x : puck + puckWidth * pos ,
y : gameZoneHeight - puckHeight ,
vx : ( pos ) * 10 ,
vy : - 5 ,
} )
}
}
2025-02-15 19:21:00 +01:00
}
render ( ) ;
requestAnimationFrame ( tick ) ;
lastTick = currentTick ;
}
function isTelekinesisActive ( ball ) {
return perks . telekinesis && ! ball . hitSinceBounce && ball . vy < 0 ;
}
function ballTick ( ball , delta ) {
ball . previousvx = ball . vx ;
ball . previousvy = ball . vy ;
2025-02-16 21:21:12 +01:00
2025-03-05 16:21:34 +01:00
let speedLimitDampener = 1 + perks . telekinesis + perks . ball_repulse_ball + perks . puck_repulse_ball + perks . ball_attract_ball
2025-03-05 15:47:12 +01:00
if ( isTelekinesisActive ( ball ) ) {
2025-03-05 16:21:34 +01:00
speedLimitDampener += 3
2025-03-05 15:47:12 +01:00
ball . vx += ( ( puck - ball . x ) / 1000 ) * delta * perks . telekinesis ;
2025-02-15 19:21:00 +01:00
}
2025-03-05 15:47:12 +01:00
2025-03-05 16:21:34 +01:00
if ( ball . vx * ball . vx + ball . vy * ball . vy < baseSpeed * baseSpeed * 2 ) {
ball . vx *= ( 1 + . 02 / speedLimitDampener ) ;
ball . vy *= ( 1 + . 02 / speedLimitDampener ) ;
} else {
ball . vx *= ( 1 - . 02 / speedLimitDampener ) ;
ball . vy *= ( 1 - . 02 / speedLimitDampener ) ;
}
// Ball could get stuck horizontally because of ball-ball interactions in repulse/attract
if ( Math . abs ( ball . vy ) < 0.2 * baseSpeed ) {
ball . vy += ( ball . vy > 0 ? 1 : - 1 ) * . 02 / speedLimitDampener
2025-03-05 15:47:12 +01:00
}
2025-02-17 00:49:03 +01:00
if ( perks . ball_repulse_ball ) {
2025-03-06 14:06:02 +01:00
for ( let 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 ) {
2025-03-06 14:06:02 +01:00
for ( let b2 of balls ) {
2025-02-17 00:49:03 +01:00
// 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 , {
2025-03-05 16:21:34 +01:00
x : puck , y : gameZoneHeight
2025-02-17 00:49:03 +01:00
} , perks . puck_repulse_ball , false )
2025-02-16 21:21:12 +01:00
}
2025-03-01 14:20:27 +01:00
if ( perks . respawn && ball . hitItem ? . length > 1 && ! isSettingOn ( 'basic' ) ) {
for ( let i = 0 ; i < ball . hitItem ? . length - 1 && i < perks . respawn ; i ++ ) {
const { index , color } = ball . hitItem [ i ]
if ( bricks [ index ] || color === 'black' ) continue
const vertical = Math . random ( ) > 0.5
const dx = Math . random ( ) > 0.5 ? 1 : - 1
const dy = Math . random ( ) > 0.5 ? 1 : - 1
flashes . push ( {
type : "particle" ,
duration : 250 ,
ethereal : true ,
time : levelTime ,
size : coinSize / 2 ,
color ,
x : brickCenterX ( index ) + dx * brickWidth / 2 ,
y : brickCenterY ( index ) + dy * brickWidth / 2 ,
vx : vertical ? 0 : - dx * baseSpeed ,
vy : vertical ? - dy * baseSpeed : 0 ,
} ) ;
}
}
2025-02-16 21:21:12 +01:00
2025-02-15 19:21:00 +01:00
const borderHitCode = bordersHitCheck ( ball , ballSize / 2 , delta ) ;
if ( borderHitCode ) {
if ( perks . sides_are_lava && borderHitCode % 2 ) {
resetCombo ( ball . x , ball . y ) ;
}
if ( perks . top_is_lava && borderHitCode >= 2 ) {
resetCombo ( ball . x , ball . y + ballSize ) ;
}
sounds . wallBeep ( ball . x ) ;
ball . bouncesList ? . push ( { x : ball.previousx , y : ball.previousy } )
}
// Puck collision
const ylimit = gameZoneHeight - puckHeight - ballSize / 2 ;
if ( ball . y > ylimit && Math . abs ( ball . x - puck ) < ballSize / 2 + puckWidth / 2 && ball . vy > 0 ) {
const speed = Math . sqrt ( ball . vx * ball . vx + ball . vy * ball . vy ) ;
const angle = Math . atan2 ( - puckWidth / 2 , ball . x - puck ) ;
ball . vx = speed * Math . cos ( angle ) ;
ball . vy = speed * Math . sin ( angle ) ;
sounds . wallBeep ( ball . x ) ;
if ( perks . streak_shots ) {
resetCombo ( ball . x , ball . y ) ;
}
2025-02-21 12:35:42 +01:00
2025-02-23 21:17:22 +01:00
if ( perks . respawn ) {
ball . hitItem . slice ( 0 , - 1 ) . slice ( 0 , perks . respawn )
2025-03-01 14:20:27 +01:00
. forEach ( ( { index , color } ) = > {
2025-03-05 16:21:34 +01:00
if ( ! bricks [ index ] && color !== 'black' ) bricks [ index ] = color
2025-03-01 14:20:27 +01:00
} )
2025-02-21 12:35:42 +01:00
}
2025-02-23 21:17:22 +01:00
ball . hitItem = [ ]
2025-02-15 19:21:00 +01:00
if ( ! ball . hitSinceBounce ) {
2025-02-23 21:17:22 +01:00
runStatistics . misses ++
2025-02-15 19:21:00 +01:00
levelMisses ++ ;
2025-03-01 14:36:44 +01:00
resetCombo ( ball . x , ball . y )
flashes . push ( {
type : "text" ,
text : 'miss' ,
duration : 500 ,
time : levelTime ,
size : puckHeight * 1.5 ,
color : 'red' ,
x : puck ,
y : gameZoneHeight - puckHeight * 2 ,
} ) ;
2025-02-15 19:21:00 +01:00
}
2025-02-23 21:17:22 +01:00
runStatistics . puck_bounces ++
2025-02-15 19:21:00 +01:00
ball . hitSinceBounce = 0 ;
2025-02-23 21:17:22 +01:00
ball . sapperUses = 0 ;
2025-02-15 19:21:00 +01:00
ball . piercedSinceBounce = 0 ;
ball . bouncesList = [ {
2025-03-05 16:21:34 +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-03-05 16:21:34 +01:00
if ( ball . sapperUses < perks . sapper && initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks
2025-02-21 12:35:42 +01:00
! bricks [ hitBrick ] ) {
2025-02-15 19:21:00 +01:00
bricks [ hitBrick ] = "black" ;
2025-02-23 21:17:22 +01:00
ball . sapperUses ++
2025-03-05 22:21:39 +01:00
2025-02-15 19:21:00 +01:00
}
2025-02-21 12:35:42 +01:00
2025-02-15 19:21:00 +01:00
}
if ( ! isSettingOn ( "basic" ) ) {
ball . sparks += ( delta * ( combo - 1 ) ) / 30 ;
if ( ball . sparks > 1 ) {
flashes . push ( {
type : "particle" ,
duration : 100 * ball . sparks ,
time : levelTime ,
size : coinSize / 2 ,
2025-03-01 20:40:20 +01:00
color : ballsColor ,
2025-02-15 19:21:00 +01:00
x : ball.x ,
y : ball.y ,
vx : ( Math . random ( ) - 0.5 ) * baseSpeed ,
vy : ( Math . random ( ) - 0.5 ) * baseSpeed ,
} ) ;
ball . sparks = 0 ;
}
}
2025-03-05 14:43:58 +01:00
2025-02-15 19:21:00 +01:00
}
2025-03-06 14:06:02 +01:00
const defaultRunStats = ( ) = > ( {
started : Date.now ( ) ,
levelsPlayed : 0 ,
runTime : 0 ,
coins_spawned : 0 ,
score : 0 ,
bricks_broken : 0 ,
misses : 0 ,
balls_lost : 0 ,
puck_bounces : 0 ,
upgrades_picked : 1 ,
max_combo : 1 ,
max_level : 0
} )
let runStatistics = defaultRunStats ( ) ;
function resetRunStatistics() {
runStatistics = defaultRunStats ( )
}
2025-02-15 19:21:00 +01:00
function getTotalScore() {
try {
return JSON . parse ( localStorage . getItem ( 'breakout_71_total_score' ) || '0' )
} catch ( e ) {
return 0
}
}
function addToTotalScore ( points ) {
try {
localStorage . setItem ( 'breakout_71_total_score' , JSON . stringify ( getTotalScore ( ) + points ) )
} catch ( e ) {
}
}
2025-02-27 23:04:42 +01:00
function addToTotalPlayTime ( ms ) {
try {
localStorage . setItem ( 'breakout_71_total_play_time' , JSON . stringify ( JSON . parse ( localStorage . getItem ( 'breakout_71_total_play_time' ) || '0' ) + ms ) )
} catch ( e ) {
}
}
2025-02-23 21:17:22 +01:00
2025-02-15 19:21:00 +01:00
function gameOver ( title , intro ) {
if ( ! running ) return ;
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-20 11:34:11 +01:00
stopRecording ( )
2025-03-01 14:20:27 +01:00
addToTotalPlayTime ( runStatistics . runTime )
runStatistics . max_level = currentLevel + 1
2025-02-15 19:21:00 +01:00
let animationDelay = - 300
const getDelay = ( ) = > {
animationDelay += 800
return 'animation-delay:' + animationDelay + 'ms;'
}
// unlocks
let unlocksInfo = ''
const endTs = getTotalScore ( )
const startTs = endTs - score
const list = getUpgraderUnlockPoints ( )
list . filter ( u = > u . threshold > startTs && u . threshold < endTs ) . forEach ( u = > {
unlocksInfo += `
2025-02-19 12:37:21 +01:00
< p class = "progress" >
2025-02-15 19:21:00 +01:00
< span > $ { u . title } < / span >
< span class = "progress_bar_part" style = "${getDelay()}" > < / span >
< / 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 } < / span >
2025-02-17 00:49:03 +01:00
< span style = "transform: scale(${scaleX},1);${getDelay()}" class = "progress_bar_part" > < / span >
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 } < / span >
< / 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-03-05 14:43:58 +01:00
2025-02-20 11:34:11 +01:00
< div id = "level-recording-container" > < / div >
2025-02-24 21:04:31 +01:00
$ { getHistograms ( true ) }
2025-02-15 19:21:00 +01:00
`
} ) . then ( ( ) = > restart ( ) ) ;
}
2025-03-01 14:20:27 +01:00
function getHistograms ( saveStats ) {
2025-02-24 21:04:31 +01:00
2025-03-01 14:20:27 +01:00
let runStats = ''
2025-02-24 21:04:31 +01:00
try {
// Stores only top 100 runs
let runsHistory = JSON . parse ( localStorage . getItem ( 'breakout_71_runs_history' ) || '[]' ) ;
2025-03-01 14:20:27 +01:00
runsHistory . sort ( ( a , b ) = > a . score - b . score ) . reverse ( )
2025-03-05 14:43:58 +01:00
runsHistory = runsHistory . slice ( 0 , 100 )
2025-03-06 14:06:02 +01:00
const nonZeroPerks = { }
2025-03-05 16:21:34 +01:00
for ( let k in perks ) {
if ( perks [ k ] ) {
2025-03-06 14:06:02 +01:00
nonZeroPerks [ k ] = perks [ k ]
2025-03-05 14:43:58 +01:00
}
}
2025-03-06 14:06:02 +01:00
runsHistory . push ( { . . . runStatistics , perks : nonZeroPerks } )
2025-02-24 21:04:31 +01:00
// Generate some histogram
2025-03-01 14:20:27 +01:00
if ( saveStats ) {
2025-02-24 21:04:31 +01:00
localStorage . setItem ( 'breakout_71_runs_history' , JSON . stringify ( runsHistory , null , 2 ) )
}
const makeHistogram = ( title , getter , unit ) = > {
let values = runsHistory . map ( h = > getter ( h ) || 0 )
2025-02-25 21:23:37 +01:00
let min = Math . min ( . . . values )
let max = Math . max ( . . . values )
2025-02-24 21:04:31 +01:00
// No point
2025-03-01 14:20:27 +01:00
if ( min === max ) return '' ;
if ( max - min < 10 ) {
// This is mostly useful for levels
min = Math . max ( 0 , max - 10 )
max = Math . max ( max , min + 10 )
2025-02-25 21:23:37 +01:00
}
2025-02-24 21:04:31 +01:00
// One bin per unique value, max 10
2025-03-01 14:20:27 +01:00
const binsCount = Math . min ( values . length , 10 )
if ( binsCount < 3 ) return ''
2025-02-24 21:04:31 +01:00
const bins = [ ]
const binsTotal = [ ]
2025-03-01 14:20:27 +01:00
for ( let i = 0 ; i < binsCount ; i ++ ) {
2025-02-24 21:04:31 +01:00
bins . push ( 0 )
binsTotal . push ( 0 )
}
const binSize = ( max - min ) / bins . length
const binIndexOf = v = > Math . min ( bins . length - 1 , Math . floor ( ( v - min ) / binSize ) )
values . forEach ( v = > {
2025-03-01 14:20:27 +01:00
if ( isNaN ( v ) ) return
const index = binIndexOf ( v )
2025-02-24 21:04:31 +01:00
bins [ index ] ++
2025-03-01 14:20:27 +01:00
binsTotal [ index ] += v
2025-02-24 21:04:31 +01:00
} )
2025-03-01 14:20:27 +01:00
if ( bins . filter ( b = > b ) . length < 3 ) return ''
2025-02-24 21:04:31 +01:00
const maxBin = Math . max ( . . . bins )
const lastValue = values [ values . length - 1 ]
const activeBin = binIndexOf ( lastValue )
2025-03-06 14:06:02 +01:00
const bars = bins . map ( ( v , vi ) = > {
const style = ` height: ${ v / maxBin * 80 } px `
return ` <span class=" ${ vi === activeBin ? 'active' : '' } "><span style=" ${ style } " title=" ${ v } run ${ v > 1 ? 's' : '' } between ${ Math . floor ( min + vi * binSize ) } and ${ Math . floor ( min + ( vi + 1 ) * binSize ) } ${ unit } "
> < span > $ { ( ! v && ' ' ) || ( vi == activeBin && lastValue + unit ) || ( Math . round ( binsTotal [ vi ] / v ) + unit ) } < / span > < / span > < / span > `
}
) . join ( '' )
return ` <h2 class="histogram-title"> ${ title } : <strong> ${ lastValue } ${ unit } </strong></h2>
< div class = "histogram" > $ { bars } < / div >
2025-02-24 21:04:31 +01:00
`
}
runStats += makeHistogram ( 'Total score' , r = > r . score , '' )
runStats += makeHistogram ( 'Catch rate' , r = > Math . round ( r . score / r . coins_spawned * 100 ) , '%' )
runStats += makeHistogram ( 'Bricks broken' , r = > r . bricks_broken , '' )
2025-03-01 14:20:27 +01:00
runStats += makeHistogram ( 'Bricks broken per minute' , r = > Math . round ( r . bricks_broken / r . runTime * 1000 * 60 ) , ' bpm' )
runStats += makeHistogram ( 'Hit rate' , r = > Math . round ( ( 1 - r . misses / r . puck_bounces ) * 100 ) , '%' )
runStats += makeHistogram ( 'Duration per level' , r = > Math . round ( r . runTime / 1000 / r . levelsPlayed ) , 's' )
2025-02-24 21:04:31 +01:00
runStats += makeHistogram ( 'Level reached' , r = > r . levelsPlayed , '' )
runStats += makeHistogram ( 'Upgrades applied' , r = > r . upgrades_picked , '' )
runStats += makeHistogram ( 'Balls lost' , r = > r . balls_lost , '' )
2025-03-01 14:20:27 +01:00
runStats += makeHistogram ( 'Average combo' , r = > Math . round ( r . coins_spawned / r . bricks_broken ) , '' )
runStats += makeHistogram ( 'Max combo' , r = > r . max_combo , '' )
2025-02-24 21:04:31 +01:00
2025-03-01 14:20:27 +01:00
if ( runStats ) {
runStats = ` <p>Find below your run statistics compared to your ${ runsHistory . length - 1 } best runs.</p> ` + runStats
2025-02-24 21:04:31 +01:00
}
} catch ( e ) {
console . warn ( e )
}
return runStats
}
2025-02-15 19:21:00 +01:00
function explodeBrick ( index , ball , isExplosion ) {
2025-03-05 14:43:58 +01:00
2025-02-15 19:21:00 +01:00
const color = bricks [ index ] ;
2025-03-01 14:20:27 +01:00
if ( ! color ) return ;
2025-02-23 21:17:22 +01:00
2025-02-15 19:21:00 +01:00
if ( color === 'black' ) {
delete bricks [ index ] ;
const x = brickCenterX ( index ) , y = brickCenterY ( index ) ;
sounds . explode ( ball . x ) ;
2025-03-01 20:40:20 +01:00
2025-03-02 09:14:39 +01:00
const col = index % gridSize
const row = Math . floor ( index / gridSize )
2025-02-15 19:21:00 +01:00
const size = 1 + perks . bigger_explosions ;
// Break bricks around
for ( let dx = - size ; dx <= size ; dx ++ ) {
for ( let dy = - size ; dy <= size ; dy ++ ) {
const i = getRowColIndex ( row + dy , col + dx ) ;
if ( bricks [ i ] && i !== - 1 ) {
explodeBrick ( i , ball , true )
}
}
}
2025-03-05 22:22:24 +01:00
2025-02-15 19:21:00 +01:00
// Blow nearby coins
coins . forEach ( ( c ) = > {
const dx = c . x - x ;
const dy = c . y - y ;
const d2 = Math . max ( brickWidth , Math . abs ( dx ) + Math . abs ( dy ) ) ;
c . vx += ( dx / d2 ) * 10 * size / c . weight ;
c . vy += ( dy / d2 ) * 10 * size / c . weight ;
} ) ;
lastexplosion = Date . now ( ) ;
flashes . push ( {
type : "ball" , duration : 150 , time : levelTime , size : brickWidth * 2 , color : "white" , x , y ,
} ) ;
2025-02-19 12:37:21 +01:00
spawnExplosion ( 7 * ( 1 + perks . bigger_explosions ) , x , y , 'white' , 150 , coinSize , ) ;
2025-02-15 19:21:00 +01:00
ball . hitSinceBounce ++ ;
2025-02-23 21:17:22 +01:00
runStatistics . bricks_broken ++
2025-02-15 19:21:00 +01:00
} else if ( color ) {
2025-02-21 12:35:42 +01:00
// Even if it bounces we don't want to count that as a miss
ball . hitSinceBounce ++ ;
2025-03-01 14:20:27 +01:00
if ( perks . sturdy_bricks && perks . sturdy_bricks > Math . random ( ) * 5 ) {
2025-02-21 12:35:42 +01:00
// Resist
sounds . coinBounce ( ball . x , 1 )
return
}
2025-02-15 19:21:00 +01:00
// Flashing is take care of by the tick loop
const x = brickCenterX ( index ) , y = brickCenterY ( index ) ;
bricks [ index ] = "" ;
2025-03-05 14:43:58 +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-03-01 14:20:27 +01:00
levelSpawnedCoins += coinsToSpawn ;
runStatistics . coins_spawned += coinsToSpawn
runStatistics . bricks_broken ++
const maxCoins = MAX_COINS * ( isSettingOn ( "basic" ) ? 0.5 : 1 )
2025-03-05 16:21:34 +01:00
const spawnableCoins = coins . length > MAX_COINS ? 1 : Math.floor ( maxCoins - coins . length ) / 3
2025-03-05 14:43:58 +01:00
2025-03-01 20:40:20 +01:00
const pointsPerCoin = Math . max ( 1 , Math . ceil ( coinsToSpawn / spawnableCoins ) )
2025-03-01 14:20:27 +01:00
2025-03-05 14:43:58 +01:00
while ( coinsToSpawn > 0 ) {
2025-03-01 14:20:27 +01:00
const points = Math . min ( pointsPerCoin , coinsToSpawn )
2025-03-05 16:21:34 +01:00
if ( points < 0 || isNaN ( points ) ) {
2025-03-05 14:43:58 +01:00
console . error ( { points } )
debugger
}
2025-03-05 22:21:39 +01:00
2025-03-01 14:20:27 +01:00
coinsToSpawn -= points
2025-03-05 19:50:17 +01:00
2025-03-06 14:06:02 +01:00
const cx = x + ( Math . random ( ) - 0.5 ) * ( brickWidth - coinSize ) ,
cy = y + ( Math . random ( ) - 0.5 ) * ( brickWidth - coinSize ) ;
2025-03-05 19:50:17 +01:00
2025-02-15 19:21:00 +01:00
coins . push ( {
2025-03-01 14:20:27 +01:00
points ,
2025-03-05 19:50:17 +01:00
color : perks.metamorphosis ? color : 'gold' ,
2025-03-06 14:06:02 +01:00
x : cx ,
y : cy ,
2025-03-05 19:50:17 +01:00
previousx : cx ,
previousy : cy ,
// Use previous speed because the ball has already bounced
2025-02-17 17:52:20 +01:00
vx : ball.previousvx * ( 0.5 + Math . random ( ) ) ,
vy : ball.previousvy * ( 0.5 + Math . random ( ) ) ,
2025-02-15 19:21:00 +01:00
sx : 0 ,
sy : 0 ,
2025-03-01 14:20:27 +01:00
a : Math.random ( ) * Math . PI * 2 ,
sa : Math.random ( ) - 0.5 ,
2025-02-15 19:21:00 +01:00
weight : 0.8 + Math . random ( ) * 0.2
} ) ;
}
2025-02-19 12:37:21 +01:00
2025-03-05 16:21:34 +01:00
combo += Math . max ( 0 , perks . streak_shots + perks . compound_interest + 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
2025-03-01 20:40:20 +01:00
if ( ( perks . picky_eater || perks . pierce_color ) && color !== ballsColor && color ) {
if ( perks . picky_eater ) {
2025-03-01 14:20:27 +01:00
resetCombo ( ball . x , ball . y ) ;
}
2025-03-01 20:40:20 +01:00
ballsColor = color ;
2025-02-15 19:21:00 +01:00
} else {
sounds . comboIncreaseMaybe ( ball . x , 1 ) ;
}
}
flashes . push ( {
type : "ball" , duration : 40 , time : levelTime , size : brickWidth , color : color , x , y ,
} ) ;
2025-03-05 16:21:34 +01:00
spawnExplosion ( 5 + Math . min ( combo , 30 ) , x , y , color , 100 , coinSize / 2 ) ;
2025-02-15 19:21:00 +01:00
}
2025-02-21 12:35:42 +01:00
2025-03-01 14:20:27 +01:00
if ( ! bricks [ index ] ) {
2025-02-21 12:35:42 +01:00
ball . hitItem ? . push ( {
2025-03-05 16:21:34 +01:00
index , color
2025-02-21 12:35:42 +01:00
} )
}
2025-02-15 19:21:00 +01:00
}
2025-03-01 14:20:27 +01:00
2025-02-15 19:21:00 +01:00
function max_levels() {
2025-03-05 19:50:17 +01:00
2025-02-15 19:21:00 +01:00
return 7 + perks . extra_levels ;
}
function render() {
if ( running ) needsRender = true
if ( ! needsRender ) {
return
}
needsRender = false ;
const level = currentLevelInfo ( ) ;
const { width , height } = canvas ;
if ( ! width || ! height ) return ;
let scoreInfo = "" ;
for ( let i = 0 ; i < perks . extra_life ; i ++ ) {
scoreInfo += "🖤 " ;
}
2025-03-01 20:40:20 +01:00
scoreInfo += 'L' + ( currentLevel + 1 ) + '/' + max_levels ( ) + ' ' ;
scoreInfo += '$' + score . toString ( ) ;
2025-03-01 14:36:44 +01:00
2025-02-15 19:21:00 +01:00
scoreDisplay . innerText = scoreInfo ;
2025-02-27 22:19:50 +01:00
// Clear
2025-03-06 14:06:02 +01:00
if ( ! isSettingOn ( "basic" ) && ! level . color && level . svg ) {
2025-02-15 19:21:00 +01:00
2025-02-18 16:03:31 +01:00
// Without this the light trails everything
2025-02-15 19:21:00 +01:00
ctx . globalCompositeOperation = "source-over" ;
2025-02-18 16:03:31 +01:00
ctx . globalAlpha = . 4
2025-02-15 19:21:00 +01:00
ctx . fillStyle = "#000" ;
ctx . fillRect ( 0 , 0 , width , height ) ;
2025-02-18 16:03:31 +01:00
2025-02-15 19:21:00 +01:00
ctx . globalCompositeOperation = "screen" ;
ctx . globalAlpha = 0.6 ;
coins . forEach ( ( coin ) = > {
if ( ! coin . destroyed ) drawFuzzyBall ( ctx , coin . color , coinSize * 2 , coin . x , coin . y ) ;
} ) ;
balls . forEach ( ( ball ) = > {
2025-03-01 20:40:20 +01:00
drawFuzzyBall ( ctx , ballsColor , ballSize * 2 , ball . x , ball . y ) ;
2025-02-15 19:21:00 +01:00
} ) ;
ctx . globalAlpha = 0.5 ;
bricks . forEach ( ( color , index ) = > {
if ( ! color ) return ;
const x = brickCenterX ( index ) , y = brickCenterY ( index ) ;
drawFuzzyBall ( ctx , color == 'black' ? '#666' : color , brickWidth , x , y ) ;
} ) ;
ctx . globalAlpha = 1 ;
flashes . forEach ( ( flash ) = > {
const { x , y , time , color , size , type , duration } = flash ;
const elapsed = levelTime - time ;
ctx . globalAlpha = Math . min ( 1 , 2 - ( elapsed / duration ) * 2 ) ;
2025-02-17 00:49:03 +01:00
if ( type === "ball" ) {
2025-02-15 19:21:00 +01:00
drawFuzzyBall ( ctx , color , size , x , y ) ;
}
2025-02-17 00:49:03 +01:00
if ( type === "particle" ) {
drawFuzzyBall ( ctx , color , size * 3 , x , y ) ;
}
2025-02-15 19:21:00 +01:00
} ) ;
2025-02-18 23:57:40 +01:00
// Decides how brights the bg black parts can get
2025-02-18 16:03:31 +01:00
ctx . globalAlpha = . 2 ;
ctx . globalCompositeOperation = "multiply" ;
2025-02-18 23:57:40 +01:00
ctx . fillStyle = "black" ;
ctx . fillRect ( 0 , 0 , width , height ) ;
2025-02-18 16:03:31 +01:00
// Decides how dark the background black parts are when lit (1=black)
ctx . globalAlpha = . 8 ;
2025-02-15 19:21:00 +01:00
ctx . globalCompositeOperation = "multiply" ;
2025-02-25 21:23:37 +01:00
if ( level . svg && background . width && background . complete ) {
2025-02-25 14:36:07 +01:00
2025-03-01 14:20:27 +01:00
if ( backgroundCanvas . title !== level . name ) {
2025-02-15 19:21:00 +01:00
backgroundCanvas . title = level . name
backgroundCanvas . width = canvas . width
backgroundCanvas . height = canvas . height
2025-03-06 14:06:02 +01:00
const bgctx = backgroundCanvas . getContext ( "2d" ) as CanvasRenderingContext2D
2025-02-25 21:23:37 +01:00
bgctx . fillStyle = level . color || '#000'
2025-02-15 19:21:00 +01:00
bgctx . fillRect ( 0 , 0 , canvas . width , canvas . height )
2025-02-25 21:23:37 +01:00
bgctx . fillStyle = ctx . createPattern ( background , "repeat" ) ;
bgctx . fillRect ( 0 , 0 , width , height ) ;
2025-02-18 16:03:31 +01:00
}
2025-02-25 21:23:37 +01:00
ctx . drawImage ( backgroundCanvas , 0 , 0 )
2025-03-01 14:20:27 +01:00
} else {
2025-02-25 21:23:37 +01:00
// Background not loaded yes
ctx . fillStyle = "#000" ;
ctx . fillRect ( 0 , 0 , width , height ) ;
2025-02-15 19:21:00 +01:00
}
} else {
ctx . globalAlpha = 1
2025-02-18 16:03:31 +01:00
ctx . globalCompositeOperation = "source-over" ;
2025-02-15 19:21:00 +01:00
ctx . fillStyle = level . color || "#000" ;
ctx . fillRect ( 0 , 0 , width , height ) ;
flashes . forEach ( ( flash ) = > {
const { x , y , time , color , size , type , duration } = flash ;
const elapsed = levelTime - time ;
ctx . globalAlpha = Math . min ( 1 , 2 - ( elapsed / duration ) * 2 ) ;
if ( type === "particle" ) {
drawBall ( ctx , color , size , x , y ) ;
}
} ) ;
}
ctx . globalAlpha = 1 ;
ctx . globalCompositeOperation = "source-over" ;
const lastExplosionDelay = Date . now ( ) - lastexplosion + 5 ;
const shaked = lastExplosionDelay < 200 ;
if ( shaked ) {
2025-02-18 23:57:40 +01:00
const amplitude = ( perks . bigger_explosions + 1 ) * 50 / lastExplosionDelay
ctx . translate ( Math . sin ( Date . now ( ) ) * amplitude , Math . sin ( Date . now ( ) + 36 ) * amplitude ) ;
2025-02-15 19:21:00 +01:00
}
ctx . globalCompositeOperation = "source-over" ;
renderAllBricks ( ctx ) ;
ctx . globalCompositeOperation = "screen" ;
flashes = flashes . filter ( ( f ) = > levelTime - f . time < f . duration && ! f . destroyed , ) ;
flashes . forEach ( ( flash ) = > {
const { x , y , time , color , size , type , text , duration , points } = flash ;
const elapsed = levelTime - time ;
ctx . globalAlpha = Math . max ( 0 , Math . min ( 1 , 2 - ( elapsed / duration ) * 2 ) ) ;
if ( type === "text" ) {
ctx . globalCompositeOperation = "source-over" ;
2025-03-01 14:20:27 +01:00
drawText ( ctx , text , color , size , x , y - elapsed / 10 ) ;
2025-02-15 19:21:00 +01:00
} else if ( type === "particle" ) {
ctx . globalCompositeOperation = "screen" ;
drawBall ( ctx , color , size , x , y ) ;
drawFuzzyBall ( ctx , color , size , x , y ) ;
}
} ) ;
// Coins
ctx . globalAlpha = 1 ;
ctx . globalCompositeOperation = "source-over" ;
coins . forEach ( ( coin ) = > {
2025-03-01 14:20:27 +01:00
if ( ! coin . destroyed ) drawCoin ( ctx , coin . color , coinSize , coin . x , coin . y , level . color || 'black' , coin . a ) ;
2025-02-15 19:21:00 +01:00
} ) ;
// Black shadow around balls
if ( coins . length > 10 && ! isSettingOn ( 'basic' ) ) {
ctx . globalAlpha = Math . min ( 0.8 , ( coins . length - 10 ) / 50 ) ;
balls . forEach ( ( ball ) = > {
drawBall ( ctx , level . color || "#000" , ballSize * 6 , ball . x , ball . y ) ;
} ) ;
}
ctx . globalAlpha = 1
ctx . globalCompositeOperation = "source-over" ;
2025-03-06 14:06:02 +01:00
const puckColor = '#FFF'
2025-02-15 19:21:00 +01:00
balls . forEach ( ( ball ) = > {
2025-03-01 20:40:20 +01:00
drawBall ( ctx , ballsColor , ballSize , ball . x , ball . y , puckColor ) ;
2025-02-15 19:21:00 +01:00
// effect
if ( isTelekinesisActive ( ball ) ) {
ctx . strokeStyle = puckColor ;
ctx . beginPath ( ) ;
ctx . bezierCurveTo ( puck , gameZoneHeight , puck , ball . y , ball . x , ball . y ) ;
ctx . stroke ( ) ;
}
} ) ;
// The puck
ctx . globalAlpha = 1
ctx . globalCompositeOperation = "source-over" ;
2025-03-01 14:20:27 +01:00
if ( perks . streak_shots && combo > baseCombo ( ) ) {
drawPuck ( ctx , 'red' , puckWidth , puckHeight , - 2 )
}
2025-02-15 19:21:00 +01:00
drawPuck ( ctx , puckColor , puckWidth , puckHeight )
if ( combo > 1 ) {
2025-02-17 00:49:03 +01:00
ctx . globalCompositeOperation = "source-over" ;
2025-03-01 14:20:27 +01:00
const comboText = "x " + combo
2025-03-02 09:14:39 +01:00
const comboTextWidth = comboText . length * puckHeight / 1.80
const totalWidth = comboTextWidth + coinSize * 2
const left = puck - totalWidth / 2
if ( totalWidth < puckWidth ) {
2025-03-06 14:06:02 +01:00
drawCoin ( ctx , 'gold' , coinSize , left + coinSize / 2 , gameZoneHeight - puckHeight / 2 , '#FFF' , 0 )
drawText ( ctx , comboText , '#000' , puckHeight , left + coinSize * 1.5 , gameZoneHeight - puckHeight / 2 , true ) ;
2025-03-02 09:14:39 +01:00
} else {
2025-03-06 14:06:02 +01:00
drawText ( ctx , comboText , '#FFF' , puckHeight , puck , gameZoneHeight - puckHeight / 2 , false ) ;
2025-03-02 09:14:39 +01:00
}
2025-02-15 19:21:00 +01:00
}
// Borders
2025-03-01 14:20:27 +01:00
const redSides = perks . sides_are_lava && combo > baseCombo ( )
ctx . fillStyle = redSides ? 'red' : puckColor ;
2025-02-15 19:21:00 +01:00
ctx . globalCompositeOperation = "source-over" ;
2025-02-18 16:03:31 +01:00
if ( offsetXRoundedDown ) {
2025-02-20 11:34:11 +01:00
// draw outside of gaming area to avoid capturing borders in recordings
ctx . fillRect ( offsetX - 1 , 0 , 1 , height ) ;
ctx . fillRect ( width - offsetX + 1 , 0 , 1 , height ) ;
2025-03-01 14:20:27 +01:00
} else if ( redSides ) {
ctx . fillRect ( 0 , 0 , 1 , height ) ;
ctx . fillRect ( width - 1 , 0 , 1 , height ) ;
2025-02-15 19:21:00 +01:00
}
2025-03-01 14:20:27 +01:00
2025-03-05 16:21:34 +01:00
if ( perks . top_is_lava && combo > baseCombo ( ) ) drawRedSquare ( ctx , offsetXRoundedDown , 0 , gameZoneWidthRoundedUp , 1 ) ;
2025-03-01 20:40:20 +01:00
const redBottom = perks . compound_interest && combo > baseCombo ( )
2025-03-01 14:20:27 +01:00
ctx . fillStyle = redBottom ? 'red' : puckColor ;
2025-02-15 19:21:00 +01:00
if ( isSettingOn ( "mobile-mode" ) ) {
2025-02-18 16:03:31 +01:00
ctx . fillRect ( offsetXRoundedDown , gameZoneHeight , gameZoneWidthRoundedUp , 1 ) ;
2025-02-15 19:21:00 +01:00
if ( ! running ) {
2025-03-05 16:21:34 +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
}
2025-03-01 14:20:27 +01:00
} else if ( redBottom ) {
ctx . fillRect ( offsetXRoundedDown , gameZoneHeight - 1 , gameZoneWidthRoundedUp , 1 ) ;
2025-02-15 19:21:00 +01:00
}
if ( shaked ) {
ctx . resetTransform ( ) ;
}
2025-02-20 11:34:11 +01:00
recordOneFrame ( )
2025-02-15 19:21:00 +01:00
}
let cachedBricksRender = document . createElement ( "canvas" ) ;
let cachedBricksRenderKey = null ;
function renderAllBricks ( destinationCtx ) {
ctx . globalAlpha = 1 ;
const level = currentLevelInfo ( ) ;
2025-03-01 14:20:27 +01:00
const redBorderOnBricksWithWrongColor = combo > baseCombo ( ) && perks . picky_eater
2025-02-15 19:21:00 +01:00
2025-03-01 20:40:20 +01:00
const newKey = gameZoneWidth + "_" + bricks . join ( "_" ) + bombSVG . complete + '_' + redBorderOnBricksWithWrongColor + '_' + ballsColor ;
2025-02-15 19:21:00 +01:00
if ( newKey !== cachedBricksRenderKey ) {
cachedBricksRenderKey = newKey ;
cachedBricksRender . width = gameZoneWidth ;
cachedBricksRender . height = gameZoneWidth + 1 ;
2025-03-06 14:06:02 +01:00
const ctx = cachedBricksRender . getContext ( "2d" ) as CanvasRenderingContext2D ;
2025-02-15 19:21:00 +01:00
ctx . clearRect ( 0 , 0 , gameZoneWidth , gameZoneWidth ) ;
ctx . resetTransform ( ) ;
ctx . translate ( - offsetX , 0 ) ;
// Bricks
2025-03-06 14:06:02 +01:00
const puckColor = '#FFF'
2025-02-15 19:21:00 +01:00
bricks . forEach ( ( color , index ) = > {
const x = brickCenterX ( index ) , y = brickCenterY ( index ) ;
if ( ! color ) return ;
2025-03-02 09:14:39 +01:00
const borderColor = ( ballsColor === color && puckColor ) || ( color !== 'black' && redBorderOnBricksWithWrongColor && 'red' ) || color
2025-03-01 14:20:27 +01:00
drawBrick ( ctx , color , borderColor , x , y ) ;
2025-02-15 19:21:00 +01:00
if ( color === 'black' ) {
ctx . globalCompositeOperation = "source-over" ;
drawIMG ( ctx , bombSVG , brickWidth , x , y ) ;
}
} ) ;
}
destinationCtx . drawImage ( cachedBricksRender , offsetX , 0 ) ;
}
let cachedGraphics = { } ;
2025-03-01 14:20:27 +01:00
function drawPuck ( ctx , color , puckWidth , puckHeight , yoffset = 0 ) {
2025-02-15 19:21:00 +01:00
const key = "puck" + color + "_" + puckWidth + '_' + puckHeight ;
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = puckWidth ;
can . height = puckHeight * 2 ;
2025-03-06 14:06:02 +01:00
const canctx = can . getContext ( "2d" ) as CanvasRenderingContext2D ;
2025-02-15 19:21:00 +01:00
canctx . fillStyle = color ;
canctx . beginPath ( ) ;
canctx . moveTo ( 0 , puckHeight * 2 )
canctx . lineTo ( 0 , puckHeight * 1.25 )
canctx . bezierCurveTo ( 0 , puckHeight * . 75 , puckWidth , puckHeight * . 75 , puckWidth , puckHeight * 1.25 )
canctx . lineTo ( puckWidth , puckHeight * 2 )
canctx . fill ( ) ;
cachedGraphics [ key ] = can ;
}
2025-03-01 14:20:27 +01:00
ctx . drawImage ( cachedGraphics [ key ] , Math . round ( puck - puckWidth / 2 ) , gameZoneHeight - puckHeight * 2 + yoffset ) ;
2025-02-15 19:21:00 +01:00
}
2025-03-01 14:20:27 +01:00
function drawBall ( ctx , color , width , x , y , borderColor = '' ) {
const key = "ball" + color + "_" + width + '_' + borderColor ;
2025-02-15 19:21:00 +01:00
2025-03-01 14:20:27 +01:00
const size = Math . round ( width ) ;
2025-02-15 19:21:00 +01:00
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = size ;
can . height = size ;
2025-03-06 14:06:02 +01:00
const canctx = can . getContext ( "2d" ) as CanvasRenderingContext2D ;
2025-02-15 19:21:00 +01:00
canctx . beginPath ( ) ;
2025-03-01 14:20:27 +01:00
canctx . arc ( size / 2 , size / 2 , Math . round ( size / 2 ) - 1 , 0 , 2 * Math . PI ) ;
2025-02-15 19:21:00 +01:00
canctx . fillStyle = color ;
canctx . fill ( ) ;
2025-03-01 14:20:27 +01:00
if ( borderColor ) {
canctx . lineWidth = 2
canctx . strokeStyle = borderColor
2025-02-27 22:19:50 +01:00
canctx . stroke ( )
}
2025-02-15 19:21:00 +01:00
cachedGraphics [ key ] = can ;
}
2025-02-27 22:19:50 +01:00
ctx . drawImage ( cachedGraphics [ key ] , Math . round ( x - size / 2 ) , Math . round ( y - size / 2 ) , ) ;
2025-02-15 19:21:00 +01:00
}
2025-03-05 16:21:34 +01:00
const angles = 32
2025-03-01 14:20:27 +01:00
function drawCoin ( ctx , color , size , x , y , bg , rawAngle ) {
2025-03-05 15:46:33 +01:00
const angle = ( Math . round ( rawAngle / Math . PI * 2 * angles ) % angles + angles ) % angles
2025-03-05 16:21:34 +01:00
const key = "coin with halo" + "_" + color + "_" + size + '_' + bg + '_' + ( color === 'gold' ? angle : 'whatever' ) ;
2025-02-15 19:21:00 +01:00
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = size ;
can . height = size ;
2025-03-06 14:06:02 +01:00
const canctx = can . getContext ( "2d" ) as CanvasRenderingContext2D ;
2025-02-15 19:21:00 +01:00
// coin
canctx . beginPath ( ) ;
2025-02-26 23:03:12 +01:00
canctx . arc ( size / 2 , size / 2 , size / 2 , 0 , 2 * Math . PI ) ;
2025-02-15 19:21:00 +01:00
canctx . fillStyle = color ;
canctx . fill ( ) ;
2025-03-01 14:20:27 +01:00
if ( color === 'gold' ) {
2025-02-15 19:21:00 +01:00
2025-02-27 22:19:50 +01:00
canctx . strokeStyle = bg ;
canctx . stroke ( ) ;
2025-02-26 23:03:12 +01:00
2025-02-27 22:19:50 +01:00
canctx . beginPath ( ) ;
canctx . arc ( size / 2 , size / 2 , size / 2 * 0.6 , 0 , 2 * Math . PI ) ;
canctx . fillStyle = 'rgba(255,255,255,0.5)' ;
canctx . fill ( ) ;
2025-02-26 23:03:12 +01:00
2025-02-27 22:19:50 +01:00
canctx . translate ( size / 2 , size / 2 ) ;
2025-03-01 14:20:27 +01:00
canctx . rotate ( angle / 16 ) ;
2025-02-27 22:19:50 +01:00
canctx . translate ( - size / 2 , - size / 2 ) ;
2025-02-26 23:03:12 +01:00
2025-03-01 14:20:27 +01:00
canctx . globalCompositeOperation = 'multiply'
drawText ( canctx , '$' , color , size - 2 , size / 2 , size / 2 + 1 )
drawText ( canctx , '$' , color , size - 2 , size / 2 , size / 2 + 1 )
2025-02-27 22:19:50 +01:00
}
2025-03-01 14:20:27 +01:00
cachedGraphics [ key ] = can ;
2025-02-15 19:21:00 +01:00
}
2025-02-26 23:03:12 +01:00
ctx . drawImage ( cachedGraphics [ key ] , Math . round ( x - size / 2 ) , Math . round ( y - size / 2 ) ) ;
2025-02-15 19:21:00 +01:00
}
function drawFuzzyBall ( ctx , color , width , x , y ) {
const key = "fuzzy-circle" + color + "_" + width ;
2025-03-05 16:21:34 +01:00
if ( ! color ) debugger
2025-02-15 19:21:00 +01:00
const size = Math . round ( width * 3 ) ;
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = size ;
can . height = size ;
2025-03-06 14:06:02 +01:00
const canctx = can . getContext ( "2d" ) as CanvasRenderingContext2D ;
2025-02-15 19:21:00 +01:00
const gradient = canctx . createRadialGradient ( size / 2 , size / 2 , 0 , size / 2 , size / 2 , size / 2 , ) ;
gradient . addColorStop ( 0 , color ) ;
gradient . addColorStop ( 1 , "transparent" ) ;
canctx . fillStyle = gradient ;
canctx . fillRect ( 0 , 0 , size , size ) ;
cachedGraphics [ key ] = can ;
}
ctx . drawImage ( cachedGraphics [ key ] , Math . round ( x - size / 2 ) , Math . round ( y - size / 2 ) , ) ;
}
2025-03-01 14:20:27 +01:00
function drawBrick ( ctx , color , borderColor , x , y ) {
2025-02-15 19:21:00 +01:00
const tlx = Math . ceil ( x - brickWidth / 2 ) ;
const tly = Math . ceil ( y - brickWidth / 2 ) ;
const brx = Math . ceil ( x + brickWidth / 2 ) - 1 ;
const bry = Math . ceil ( y + brickWidth / 2 ) - 1 ;
const width = brx - tlx , height = bry - tly ;
2025-03-01 14:20:27 +01:00
const key = "brick" + color + '_' + borderColor + "_" + width + "_" + height
2025-02-15 19:21:00 +01:00
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = width ;
can . height = height ;
2025-03-03 20:51:08 +01:00
const bord = 2 ;
2025-03-05 14:43:58 +01:00
const cornerRadius = 2
2025-03-06 14:06:02 +01:00
const canctx = can . getContext ( "2d" ) as CanvasRenderingContext2D ;
2025-02-15 19:21:00 +01:00
2025-03-03 20:51:08 +01:00
2025-03-01 14:20:27 +01:00
canctx . fillStyle = color ;
canctx . strokeStyle = borderColor ;
canctx . lineJoin = "round" ;
canctx . lineWidth = bord
2025-03-05 14:43:58 +01:00
roundRect ( canctx , bord / 2 , bord / 2 , width - bord , height - bord , cornerRadius )
2025-03-03 20:51:08 +01:00
canctx . fill ( ) ;
canctx . stroke ( ) ;
2025-02-15 19:21:00 +01:00
cachedGraphics [ key ] = can ;
}
ctx . drawImage ( cachedGraphics [ key ] , tlx , tly , width , height ) ;
// It's not easy to have a 1px gap between bricks without antialiasing
}
2025-03-05 16:21:34 +01:00
function roundRect ( ctx , x , y , width , height , radius ) {
2025-03-05 14:43:58 +01:00
ctx . beginPath ( ) ;
ctx . moveTo ( x + radius , y ) ;
ctx . lineTo ( x + width - radius , y ) ;
ctx . quadraticCurveTo ( x + width , y , x + width , y + radius ) ;
ctx . lineTo ( x + width , y + height - radius ) ;
ctx . quadraticCurveTo ( x + width , y + height , x + width - radius , y + height ) ;
ctx . lineTo ( x + radius , y + height ) ;
ctx . quadraticCurveTo ( x , y + height , x , y + height - radius ) ;
ctx . lineTo ( x , y + radius ) ;
ctx . quadraticCurveTo ( x , y , x + radius , y ) ;
ctx . closePath ( ) ;
2025-03-03 20:51:08 +01:00
}
2025-03-05 14:43:58 +01:00
2025-03-01 14:20:27 +01:00
function drawRedSquare ( ctx , x , y , width , height ) {
ctx . fillStyle = 'red'
ctx . fillRect ( x , y , width , height ) ;
2025-02-15 19:21:00 +01:00
}
function drawIMG ( ctx , img , size , x , y ) {
const key = "svg" + img + "_" + size + '_' + img . complete ;
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = size ;
can . height = size ;
2025-03-06 14:06:02 +01:00
const canctx = can . getContext ( "2d" ) as CanvasRenderingContext2D ;
2025-02-15 19:21:00 +01:00
const ratio = size / Math . max ( img . width , img . height ) ;
const w = img . width * ratio ;
const h = img . height * ratio ;
canctx . drawImage ( img , ( size - w ) / 2 , ( size - h ) / 2 , w , h ) ;
cachedGraphics [ key ] = can ;
}
ctx . drawImage ( cachedGraphics [ key ] , Math . round ( x - size / 2 ) , Math . round ( y - size / 2 ) , ) ;
}
2025-03-01 14:20:27 +01:00
function drawText ( ctx , text , color , fontSize , x , y , left = false ) {
const key = "text" + text + "_" + color + "_" + fontSize + '_' + left ;
2025-02-15 19:21:00 +01:00
if ( ! cachedGraphics [ key ] ) {
const can = document . createElement ( "canvas" ) ;
can . width = fontSize * text . length ;
can . height = fontSize ;
2025-03-06 14:06:02 +01:00
const canctx = can . getContext ( "2d" ) as CanvasRenderingContext2D ;
2025-02-15 19:21:00 +01:00
canctx . fillStyle = color ;
2025-03-01 14:20:27 +01:00
canctx . textAlign = left ? 'left' : "center"
2025-02-15 19:21:00 +01:00
canctx . textBaseline = "middle" ;
canctx . font = fontSize + "px monospace" ;
2025-02-26 23:03:12 +01:00
canctx . fillText ( text , left ? 0 : can.width / 2 , can . height / 2 , can . width ) ;
2025-02-15 19:21:00 +01:00
cachedGraphics [ key ] = can ;
}
2025-03-01 14:20:27 +01:00
ctx . drawImage ( cachedGraphics [ key ] , left ? x : Math.round ( x - cachedGraphics [ key ] . width / 2 ) , Math . round ( y - cachedGraphics [ key ] . height / 2 ) , ) ;
2025-02-15 19:21:00 +01:00
}
function pixelsToPan ( pan ) {
return ( pan - offsetX ) / gameZoneWidth ;
}
let lastComboPlayed = NaN , shepard = 6 ;
function playShepard ( delta , pan , volume ) {
const shepardMax = 11 , factor = 1.05945594920268 , baseNote = 392 ;
shepard += delta ;
if ( shepard > shepardMax ) shepard = 0 ;
if ( shepard < 0 ) shepard = shepardMax ;
const play = ( note ) = > {
const freq = baseNote * Math . pow ( factor , note ) ;
const diff = Math . abs ( note - shepardMax * 0.5 ) ;
const maxDistanceToIdeal = 1.5 * shepardMax ;
const vol = Math . max ( 0 , volume * ( 1 - diff / maxDistanceToIdeal ) ) ;
createSingleBounceSound ( freq , pan , vol ) ;
return freq . toFixed ( 2 ) + " at " + Math . floor ( vol * 100 ) + "% diff " + diff ;
} ;
play ( 1 + shepardMax + shepard ) ;
play ( shepard ) ;
play ( - 1 - shepardMax + shepard ) ;
}
const sounds = {
wallBeep : ( pan ) = > {
if ( ! isSettingOn ( "sound" ) ) return ;
createSingleBounceSound ( 800 , pixelsToPan ( pan ) ) ;
} ,
comboIncreaseMaybe : ( x , volume ) = > {
if ( ! isSettingOn ( "sound" ) ) return ;
let delta = 0 ;
if ( ! isNaN ( lastComboPlayed ) ) {
if ( lastComboPlayed < combo ) delta = 1 ;
if ( lastComboPlayed > combo ) delta = - 1 ;
}
playShepard ( delta , pixelsToPan ( x ) , volume ) ;
lastComboPlayed = combo ;
} ,
comboDecrease() {
if ( ! isSettingOn ( "sound" ) ) return ;
playShepard ( - 1 , 0.5 , 0.5 ) ;
2025-03-05 16:21:34 +01:00
} , coinBounce : ( pan , volume ) = > {
2025-02-15 19:21:00 +01:00
if ( ! isSettingOn ( "sound" ) ) return ;
2025-03-01 14:20:27 +01:00
createSingleBounceSound ( 1200 , pixelsToPan ( pan ) , volume , 0.1 , 'triangle' ) ;
2025-02-15 19:21:00 +01:00
} , explode : ( pan ) = > {
if ( ! isSettingOn ( "sound" ) ) return ;
createExplosionSound ( pixelsToPan ( pan ) ) ;
} , revive : ( ) = > {
if ( ! isSettingOn ( "sound" ) ) return ;
createRevivalSound ( 500 ) ;
} , coinCatch ( pan ) {
if ( ! isSettingOn ( "sound" ) ) return ;
2025-03-01 14:20:27 +01:00
createSingleBounceSound ( 900 , pixelsToPan ( pan ) , . 8 , 0.1 , 'triangle' )
2025-02-15 19:21:00 +01:00
}
} ;
// How to play the code on the leftconst context = new window.AudioContext();
2025-02-25 14:36:07 +01:00
let audioContext , audioRecordingTrack ;
2025-02-15 19:21:00 +01:00
function getAudioContext() {
if ( ! audioContext ) {
audioContext = new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
2025-02-25 14:36:07 +01:00
audioRecordingTrack = audioContext . createMediaStreamDestination ( )
2025-02-15 19:21:00 +01:00
}
return audioContext ;
}
2025-02-26 23:03:12 +01:00
function createSingleBounceSound ( baseFreq = 800 , pan = 0.5 , volume = 1 , duration = 0.1 , type = "sine" ) {
2025-02-15 19:21:00 +01:00
const context = getAudioContext ( ) ;
// Frequency for the metal "ping"
const baseFrequency = baseFreq ; // Hz
// Create an oscillator for the impact sound
const oscillator = context . createOscillator ( ) ;
2025-03-01 14:20:27 +01:00
oscillator . type = type ;
2025-02-15 19:21:00 +01:00
oscillator . frequency . setValueAtTime ( baseFrequency , context . currentTime ) ;
// Create a gain node to control the volume
const gainNode = context . createGain ( ) ;
oscillator . connect ( gainNode ) ;
// Create a stereo panner node for left-right panning
const panner = context . createStereoPanner ( ) ;
panner . pan . setValueAtTime ( pan * 2 - 1 , context . currentTime ) ;
gainNode . connect ( panner ) ;
panner . connect ( context . destination ) ;
2025-02-25 14:36:07 +01:00
panner . connect ( audioRecordingTrack ) ;
2025-02-15 19:21:00 +01:00
// Set up the gain envelope to simulate the impact and quick decay
gainNode . gain . setValueAtTime ( 0.8 * volume , context . currentTime ) ; // Initial impact
gainNode . gain . exponentialRampToValueAtTime ( 0.001 , context . currentTime + duration , ) ; // Quick decay
// Start the oscillator
oscillator . start ( context . currentTime ) ;
// Stop the oscillator after the decay
oscillator . stop ( context . currentTime + duration ) ;
}
function createRevivalSound ( baseFreq = 440 ) {
const context = getAudioContext ( ) ;
// Create multiple oscillators for a richer sound
const oscillators = [ context . createOscillator ( ) , context . createOscillator ( ) , context . createOscillator ( ) , ] ;
// Set the type and frequency for each oscillator
oscillators . forEach ( ( osc , index ) = > {
osc . type = "sine" ;
osc . frequency . setValueAtTime ( baseFreq + index * 2 , context . currentTime ) ; // Slight detuning
} ) ;
// Create a gain node to control the volume
const gainNode = context . createGain ( ) ;
// Connect all oscillators to the gain node
oscillators . forEach ( ( osc ) = > osc . connect ( gainNode ) ) ;
// Create a stereo panner node for left-right panning
const panner = context . createStereoPanner ( ) ;
panner . pan . setValueAtTime ( 0 , context . currentTime ) ; // Center panning
gainNode . connect ( panner ) ;
panner . connect ( context . destination ) ;
2025-02-25 14:36:07 +01:00
panner . connect ( audioRecordingTrack ) ;
2025-02-15 19:21:00 +01:00
// Set up the gain envelope to simulate a smooth attack and decay
gainNode . gain . setValueAtTime ( 0 , context . currentTime ) ; // Start at zero
2025-03-02 09:14:39 +01:00
gainNode . gain . linearRampToValueAtTime ( 0.5 , context . currentTime + 0.5 ) ; // Ramp up to full volume
2025-02-15 19:21:00 +01:00
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-03-06 14:06:02 +01:00
document . body . className = ( running ? " running " : " paused " ) ;
2025-02-15 19:21:00 +01:00
} , 100 ) ;
window . addEventListener ( "visibilitychange" , ( ) = > {
if ( document . hidden ) {
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-15 19:21:00 +01:00
}
} ) ;
const scoreDisplay = document . getElementById ( "score" ) ;
2025-03-01 14:20:27 +01:00
let alertsOpen = 0 , closeModal = null
2025-02-15 19:21:00 +01:00
function asyncAlert ( {
title ,
text ,
2025-03-06 14:06:02 +01:00
actions = [ { text : "OK" , value : "ok" , help : "" , disabled : false , icon : '' } ] ,
2025-02-15 19:21:00 +01:00
allowClose = true ,
textAfterButtons = ''
} ) {
2025-02-27 18:56:04 +01:00
alertsOpen ++
2025-02-15 19:21:00 +01:00
return new Promise ( ( resolve ) = > {
const popupWrap = document . createElement ( "div" ) ;
document . body . appendChild ( popupWrap ) ;
popupWrap . className = "popup" ;
function closeWithResult ( value ) {
resolve ( value ) ;
// Doing this async lets the menu scroll persist if it's shown a second time
setTimeout ( ( ) = > {
document . body . removeChild ( popupWrap ) ;
} ) ;
}
if ( allowClose ) {
const closeButton = document . createElement ( "button" ) ;
closeButton . title = "close"
closeButton . className = "close-modale"
closeButton . addEventListener ( 'click' , ( e ) = > {
e . preventDefault ( )
closeWithResult ( null )
} )
2025-03-01 14:20:27 +01:00
closeModal = ( ) = > {
2025-02-27 18:56:04 +01:00
closeWithResult ( null )
}
2025-02-15 19:21:00 +01:00
popupWrap . appendChild ( closeButton )
}
const popup = document . createElement ( "div" ) ;
if ( title ) {
const p = document . createElement ( "h2" ) ;
p . innerHTML = title ;
popup . appendChild ( p ) ;
}
if ( text ) {
const p = document . createElement ( "div" ) ;
p . innerHTML = text ;
popup . appendChild ( p ) ;
}
2025-03-06 14:06:02 +01:00
actions . filter ( i = > i ) . forEach ( ( { text , value , help , disabled , icon = '' } ) = > {
2025-02-15 19:21:00 +01:00
const button = document . createElement ( "button" ) ;
2025-03-06 14:06:02 +01:00
2025-02-19 12:37:21 +01:00
button . innerHTML = `
$ { icon }
2025-02-15 19:21:00 +01:00
< div >
< strong > $ { text } < / strong >
< em > $ { help || '' } < / em >
< / div > ` ;
if ( disabled ) {
button . setAttribute ( "disabled" , "disabled" ) ;
} else {
button . addEventListener ( "click" , ( e ) = > {
e . preventDefault ( ) ;
closeWithResult ( value )
} ) ;
}
popup . appendChild ( button ) ;
} ) ;
if ( textAfterButtons ) {
const p = document . createElement ( "div" ) ;
p . className = 'textAfterButtons'
p . innerHTML = textAfterButtons ;
popup . appendChild ( p ) ;
}
popupWrap . appendChild ( popup ) ;
2025-03-06 14:06:02 +01:00
( popup . querySelector ( 'button:not([disabled])' ) as HTMLButtonElement ) ? . focus ( )
} ) . then ( ( v ) = > {
alertsOpen --
closeModal = null
return v
} , ( ) = > {
closeModal = null
2025-02-27 18:56:04 +01:00
alertsOpen --
} )
2025-02-15 19:21:00 +01:00
}
// Settings
let cachedSettings = { } ;
function isSettingOn ( key ) {
if ( typeof cachedSettings [ key ] == "undefined" ) {
try {
cachedSettings [ key ] = JSON . parse ( localStorage . getItem ( "breakout-settings-enable-" + key ) , ) ;
} catch ( e ) {
console . warn ( e ) ;
}
}
return cachedSettings [ key ] ? ? options [ key ] ? . default ? ? false ;
}
function toggleSetting ( key ) {
cachedSettings [ key ] = ! isSettingOn ( key ) ;
try {
const lskey = "breakout-settings-enable-" + key ;
localStorage . setItem ( lskey , JSON . stringify ( cachedSettings [ key ] ) ) ;
} catch ( e ) {
console . warn ( e ) ;
}
if ( options [ key ] . afterChange ) options [ key ] . afterChange ( ) ;
}
2025-02-27 18:56:04 +01:00
2025-02-15 19:21:00 +01:00
scoreDisplay . addEventListener ( "click" , async ( e ) = > {
e . preventDefault ( ) ;
2025-03-01 14:20:27 +01:00
openScorePanel ( )
2025-02-27 18:56:04 +01:00
} ) ;
2025-03-01 14:20:27 +01:00
async function openScorePanel() {
2025-02-27 18:56:04 +01:00
pause ( true )
2025-02-15 19:21:00 +01:00
const cb = await asyncAlert ( {
2025-03-05 16:21:34 +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-03-05 16:21:34 +01:00
` , allowClose: true, actions: [{
text : 'Resume' , help : "Return to your run" ,
} , {
text : "Restart" , help : "Start a brand new run." , value : ( ) = > {
restart ( ) ;
return true ;
2025-02-27 18:56:04 +01:00
} ,
2025-03-05 16:21:34 +01:00
} ] ,
2025-02-15 19:21:00 +01:00
} ) ;
if ( cb ) {
await cb ( )
}
2025-02-27 18:56:04 +01:00
}
2025-02-15 19:21:00 +01:00
document . getElementById ( "menu" ) . addEventListener ( "click" , ( e ) = > {
e . preventDefault ( ) ;
openSettingsPanel ( ) ;
} ) ;
2025-02-20 12:07:42 +01:00
2025-02-15 19:21:00 +01:00
const options = {
sound : {
2025-03-05 16:21:34 +01:00
default : true , name : ` Game sounds ` , help : ` Can slow down some phones. ` , 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-03-05 16:21:34 +01:00
} , basic : {
default : false , name : ` Basic graphics ` , help : ` Better performance on older devices. ` , disabled : ( ) = > false
} , pointerLock : {
default : false ,
name : ` Mouse pointer lock ` ,
2025-02-27 19:11:31 +01:00
help : ` Locks and hides the mouse cursor. ` ,
disabled : ( ) = > ! canvas . requestPointerLock
2025-03-05 16:21:34 +01:00
} , "easy" : {
default : false ,
name : ` Kids mode ` ,
2025-03-06 14:06:02 +01:00
help : ` Start future runs with "slower ball". ` ,
2025-02-23 21:17:22 +01:00
disabled : ( ) = > false
2025-03-05 16:21:34 +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-03-06 14:06:02 +01:00
default : false ,
name : ` Record gameplay videos ` ,
help : ` Get a video of each level. ` ,
disabled() {
2025-02-23 21:17:22 +01:00
return window . location . search . includes ( 'isInWebView=true' )
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-03-05 16:21:34 +01:00
if ( options [ key ] ) optionsList . push ( {
disabled : options [ key ] . disabled ( ) ,
2025-03-06 14:06:02 +01:00
icon : isSettingOn ( key ) ? icons [ 'icon:checkmark_checked' ] : icons [ 'icon:checkmark_unchecked' ] ,
2025-03-05 16:21:34 +01:00
text : options [ key ] . name ,
help : options [ key ] . help ,
value : ( ) = > {
toggleSetting ( key )
2025-03-06 14:06:02 +01:00
openSettingsPanel ( ) ;
2025-03-05 16:21:34 +01:00
} ,
} ) ;
2025-02-15 19:21:00 +01:00
}
const cb = await asyncAlert ( {
title : "Breakout 71" , text : `
2025-03-05 16:21:34 +01:00
` , allowClose: true, actions: [{
text : 'Resume' , help : "Return to your run" , async value() {
2025-02-27 18:56:04 +01:00
2025-03-05 16:21:34 +01:00
}
} , {
2025-03-05 19:50:17 +01:00
text : 'Starting perk' , help : "Try perks and levels you unlocked" , async value() {
2025-03-05 16:21:34 +01:00
const ts = getTotalScore ( )
const actions = [ . . . upgrades
. sort ( ( a , b ) = > a . threshold - b . threshold )
. map ( ( {
2025-03-06 14:06:02 +01:00
name , help , id , threshold , icon , fullHelp
2025-03-05 16:21:34 +01:00
} ) = > ( {
text : name ,
help : ts >= threshold ? fullHelp || help : ` Unlocks at total score ${ threshold } . ` ,
disabled : ts < threshold ,
2025-03-06 14:06:02 +01:00
value : { perks : { [ id ] : 1 } } ,
icon : icons [ 'icon:' + id ]
2025-03-05 16:21:34 +01:00
} ) )
, . . . allLevels
2025-02-19 21:11:22 +01:00
. sort ( ( a , b ) = > a . threshold - b . threshold )
2025-03-05 16:21:34 +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 } ,
2025-03-06 14:06:02 +01:00
icon : icons [ l . name ]
2025-02-19 21:11:22 +01:00
} )
2025-03-05 16:21:34 +01:00
} ) ]
2025-02-17 00:49:03 +01:00
2025-03-05 16:21:34 +01:00
const tryOn = await asyncAlert ( {
title : ` You unlocked ${ Math . round ( actions . filter ( a = > ! a . disabled ) . length / actions . length * 100 ) } % of the game. ` ,
text : `
2025-02-19 12:37:21 +01:00
< 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-03-05 16:21:34 +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 } .
2025-03-05 19:50:17 +01:00
Click an item above to start a run with it .
2025-02-19 12:37:21 +01:00
< / p > ` ,
2025-03-05 16:21:34 +01:00
actions ,
allowClose : true ,
} )
if ( tryOn ) {
if ( ! currentLevel || await asyncAlert ( {
title : 'Restart run to try this item?' ,
2025-03-05 19:50:17 +01:00
text : 'You\'re about to start a new run with the selected unlocked item, is that really what you wanted ? ' ,
2025-03-05 16:21:34 +01:00
actions : [ {
value : true , text : 'Restart game to test item'
} , {
value : false , text : 'Cancel'
} ]
} ) ) nextRunOverrides = tryOn
restart ( )
2025-02-17 00:49:03 +01:00
}
2025-03-05 16:21:34 +01:00
}
} ,
2025-02-17 00:49:03 +01:00
2025-02-15 19:21:00 +01:00
. . . optionsList ,
2025-03-05 16:21:34 +01:00
( document . fullscreenEnabled || document . webkitFullscreenEnabled ) && ( document . fullscreenElement !== null ? {
2025-03-06 14:06:02 +01:00
text : "Exit Fullscreen" ,
icon :icons [ 'icon:exit_fullscreen' ] ,
help : "Might not work on some machines" , value() {
2025-03-05 16:21:34 +01:00
toggleFullScreen ( )
}
} : {
2025-03-06 14:06:02 +01:00
icon :icons [ 'icon:fullscreen' ] ,
2025-03-05 16:21:34 +01:00
text : "Fullscreen" , help : "Might not work on some machines" , value() {
toggleFullScreen ( )
}
} ) , {
text : 'Reset Game' , help : "Erase high score and statistics" , async value() {
2025-02-15 19:21:00 +01:00
if ( await asyncAlert ( {
2025-03-05 16:21:34 +01:00
title : 'Reset' , actions : [ {
text : 'Yes' , value : true
} , {
text : 'No' , value : false
} ] , allowClose : true ,
2025-02-15 19:21:00 +01:00
} ) ) {
localStorage . clear ( )
window . location . reload ( )
}
}
2025-03-05 16:21:34 +01:00
} ] , textAfterButtons : `
2025-02-21 12:35:42 +01:00
< p >
< span > Made in France by < a href = "https://lecaro.me" > Renan LE CARO < / a > . < / span >
2025-03-05 22:10:17 +01:00
< a href = "https://breakout.lecaro.me/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-03-05 22:10:17 +01:00
< span > v . $ { appVersion } < / span >
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
}
2025-03-01 21:39:28 +01:00
let mediaRecorder , captureStream , recordCanvas , recordCanvasCtx
2025-02-20 11:34:11 +01:00
function recordOneFrame() {
if ( ! isSettingOn ( 'record' ) ) {
return
}
if ( ! running ) return ;
2025-03-01 14:36:44 +01:00
if ( ! captureStream ) return ;
2025-02-20 11:34:11 +01:00
drawMainCanvasOnSmallCanvas ( )
if ( captureStream . requestFrame ) {
captureStream . requestFrame ( )
} else {
captureStream . getVideoTracks ( ) [ 0 ] . requestFrame ( )
}
}
function drawMainCanvasOnSmallCanvas() {
2025-03-01 14:20:27 +01:00
if ( ! recordCanvasCtx ) return
2025-02-24 21:04:31 +01:00
recordCanvasCtx . drawImage ( canvas , offsetXRoundedDown , 0 , gameZoneWidthRoundedUp , gameZoneHeight , 0 , 0 , recordCanvas . width , recordCanvas . height )
2025-03-05 15:21:32 +01:00
// Here we don't use drawText as we don't want to cache a picture for each distinct value of score
2025-03-06 14:06:02 +01:00
recordCanvasCtx . fillStyle = '#FFF'
2025-02-20 11:34:11 +01:00
recordCanvasCtx . textBaseline = "top" ;
recordCanvasCtx . font = "12px monospace" ;
recordCanvasCtx . textAlign = "right" ;
recordCanvasCtx . fillText ( score . toString ( ) , recordCanvas . width - 12 , 12 )
2025-02-25 14:36:07 +01:00
2025-02-20 11:34:11 +01:00
recordCanvasCtx . textAlign = "left" ;
2025-03-01 14:20:27 +01:00
recordCanvasCtx . fillText ( 'Level ' + ( currentLevel + 1 ) + '/' + max_levels ( ) , 12 , 12 )
2025-02-20 11:34:11 +01:00
}
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 } )
2025-03-01 14:20:27 +01:00
captureStream = recordCanvas . captureStream ( 0 ) ;
2025-02-25 14:36:07 +01:00
2025-03-01 14:20:27 +01:00
if ( isSettingOn ( 'sound' ) && getAudioContext ( ) && audioRecordingTrack ) {
2025-02-25 14:36:07 +01:00
captureStream . addTrack ( audioRecordingTrack . stream . getAudioTracks ( ) [ 0 ] )
// captureStream.addTrack(audioRecordingTrack.stream.getAudioTracks()[1])
}
2025-02-20 11:34:11 +01:00
}
2025-02-24 21:04:31 +01:00
recordCanvas . width = gameZoneWidthRoundedUp
recordCanvas . height = gameZoneHeight
2025-02-21 00:30:28 +01:00
2025-02-20 11:34:11 +01:00
// drawMainCanvasOnSmallCanvas()
const recordedChunks = [ ] ;
2025-02-25 14:36:07 +01:00
2025-03-02 09:14:39 +01:00
const instance = new MediaRecorder ( captureStream , { videoBitsPerSecond : 3500000 } ) ;
2025-02-20 11:34:11 +01:00
mediaRecorder = instance
instance . start ( ) ;
mediaRecorder . pause ( )
instance . ondataavailable = function ( event ) {
recordedChunks . push ( event . data ) ;
}
instance . onstop = async function ( ) {
2025-03-01 14:20:27 +01:00
let targetDiv ;
2025-02-24 21:04:31 +01:00
let blob = new Blob ( recordedChunks , { type : "video/webm" } ) ;
2025-03-01 14:20:27 +01:00
if ( blob . size < 200000 ) return // under 0.2MB, probably bugged out or pointlessly short
2025-02-24 21:04:31 +01:00
2025-03-01 14:20:27 +01:00
while ( ! ( targetDiv = document . getElementById ( "level-recording-container" ) ) ) {
await new Promise ( r = > setTimeout ( r , 200 ) )
2025-02-24 21:04:31 +01:00
}
2025-02-20 11:34:11 +01:00
const video = document . createElement ( "video" )
video . autoplay = true
video . controls = false
video . disablepictureinpicture = true
video . disableremoteplayback = true
video . width = recordCanvas . width
video . height = recordCanvas . height
2025-02-25 14:36:07 +01:00
// targetDiv.style.width = recordCanvas.width + 'px'
// targetDiv.style.height = recordCanvas.height + 'px'
2025-02-20 11:34:11 +01:00
video . loop = true
video . muted = true
video . playsinline = true
video . src = URL . createObjectURL ( blob ) ;
const a = document . createElement ( "a" )
a . download = captureFileName ( 'webm' )
a . target = "_blank"
a . href = video . src
a . textContent = ` Download video ( ${ ( blob . size / 1000000 ) . toFixed ( 2 ) } MB) `
targetDiv . appendChild ( video )
targetDiv . appendChild ( a )
}
}
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 ( )
mediaRecorder = null
}
function captureFileName ( ext ) {
return "breakout-71-capture-" + new Date ( ) . toISOString ( ) . replace ( /[^0-9\-]+/gi , '-' ) + '.' + ext
}
2025-03-01 14:20:27 +01:00
function findLast ( arr , predicate ) {
let i = arr . length
2025-03-05 16:21:34 +01:00
while ( -- i ) if ( predicate ( arr [ i ] , i , arr ) ) {
return arr [ i ]
}
2025-02-23 21:17:22 +01:00
2025-03-01 14:20:27 +01:00
}
2025-02-23 21:17:22 +01:00
2025-03-01 14:20:27 +01:00
function toggleFullScreen() {
try {
if ( document . fullscreenElement !== null ) {
if ( document . exitFullscreen ) {
document . exitFullscreen ( ) ;
} else if ( document . webkitCancelFullScreen ) {
document . webkitCancelFullScreen ( ) ;
2025-02-26 20:44:47 +01:00
}
2025-03-01 14:20:27 +01:00
} else {
const docel = document . documentElement
if ( docel . requestFullscreen ) {
docel . requestFullscreen ( ) ;
} else if ( docel . webkitRequestFullscreen ) {
docel . webkitRequestFullscreen ( ) ;
2025-02-27 18:56:04 +01:00
}
}
2025-03-01 14:20:27 +01:00
} catch ( e ) {
console . warn ( e )
}
2025-02-27 18:56:04 +01:00
}
2025-03-01 14:20:27 +01:00
const pressed = {
2025-03-05 16:21:34 +01:00
ArrowLeft : 0 , ArrowRight : 0 , Shift : 0
2025-02-27 18:56:04 +01:00
}
2025-03-01 14:20:27 +01:00
function setKeyPressed ( key , on ) {
pressed [ key ] = on
keyboardPuckSpeed = ( pressed . ArrowRight - pressed . ArrowLeft ) * ( 1 + pressed . Shift * 2 ) * gameZoneWidth / 50
2025-02-27 18:56:04 +01:00
}
2025-03-01 14:20:27 +01:00
2025-03-04 21:52:21 +01:00
document . addEventListener ( 'keydown' , e = > {
2025-03-03 22:18:38 +01:00
if ( e . key . toLowerCase ( ) === 'f' && ! e . ctrlKey && ! e . metaKey ) {
2025-02-27 18:56:04 +01:00
toggleFullScreen ( )
2025-03-01 14:20:27 +01:00
} else if ( e . key in pressed ) {
setKeyPressed ( e . key , 1 )
}
if ( e . key === ' ' && ! alertsOpen ) {
if ( running ) {
2025-02-27 18:56:04 +01:00
pause ( )
} else {
play ( )
}
2025-03-01 14:20:27 +01:00
} else {
2025-02-27 18:56:04 +01:00
return
}
e . preventDefault ( )
} )
2025-03-01 14:20:27 +01:00
document . addEventListener ( 'keyup' , e = > {
if ( e . key in pressed ) {
setKeyPressed ( e . key , 0 )
} else if ( e . key === 'ArrowDown' && document . querySelector ( 'button:focus' ) ? . nextElementSibling . tagName === 'BUTTON' ) {
document . querySelector ( 'button:focus' ) ? . nextElementSibling ? . focus ( )
} else if ( e . key === 'ArrowUp' && document . querySelector ( 'button:focus' ) ? . previousElementSibling . tagName === 'BUTTON' ) {
document . querySelector ( 'button:focus' ) ? . previousElementSibling ? . focus ( )
} else if ( e . key === 'Escape' && closeModal ) {
closeModal ( )
} else if ( e . key === 'Escape' && running ) {
pause ( )
} else if ( e . key . toLowerCase ( ) === 'm' && ! alertsOpen ) {
openSettingsPanel ( )
} else if ( e . key . toLowerCase ( ) === 's' && ! alertsOpen ) {
openScorePanel ( )
} else {
2025-02-27 18:56:04 +01:00
return
}
e . preventDefault ( )
} )
2025-02-23 21:17:22 +01:00
2025-02-15 19:21:00 +01:00
fitSize ( )
restart ( )
tick ( ) ;