Trying to get file dowload to work

This commit is contained in:
Renan LE CARO 2025-03-18 14:16:12 +01:00
parent 5ca2d58c9d
commit ffdbd71a88
28 changed files with 1525 additions and 5236 deletions

View file

@ -3,6 +3,8 @@
Break colourful bricks, catch bouncing coins and select powerful upgrades !
- [Play now](https://breakout.lecaro.me/)
- [Donate](https://paypal.me/renanlecaro)
- [Discord](https://discord.gg/DZSPqyJkwP)
- [Post your comments on itch.io](https://renanlecaro.itch.io/breakout71)
- [Help and tips about the game](./Help.md)
- [Credits](./Credits.md)
@ -10,7 +12,6 @@ Break colourful bricks, catch bouncing coins and select powerful upgrades !
- [Google Play](https://play.google.com/store/apps/details?id=me.lecaro.breakout)
- [GitLab](https://gitlab.com/lecarore/breakout71)
- [HackerNews thread](https://news.ycombinator.com/item?id=43183131)
- [Donate](https://github.com/sponsors/renanlecaro)
# System requirements
@ -22,8 +23,7 @@ There's also an easy mode for kids (slower ball).
# Next
- separate particles by type, reuse coins and particles
- check which color you get if picking a color related perk
- sturdy bricks map of remaining hits
# bugs
@ -159,6 +159,9 @@ There's also an easy mode for kids (slower ball).
- [colin] perk: analyzer - permet de voir les caractéristiques cachées des blocs (sturdy…)
- [colin] perk: roulette - gagne instantanément 2 perks aléatoires
- let coins go out of bounds left and right, where they'll get lost, but +1 combo per brick
- more combo if no coin catch
- combo climbs every time a ball bounces on puck (but bounce is random?)
-
# extra levels

View file

@ -11,8 +11,8 @@ android {
applicationId = "me.lecaro.breakout"
minSdk = 21
targetSdk = 34
versionCode = 29036807
versionName = "29036807"
versionCode = 29038230
versionName = "29038230"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true

View file

@ -1,8 +1,12 @@
<?xml version="1.0" encoding ="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application
android:requestLegacyExternalStorage="true"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,10 @@
package me.lecaro.breakout
import android.app.Activity
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.os.Environment
@ -18,8 +20,11 @@ import android.widget.Toast
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.jar.Manifest
const val CHOOSE_FILE_REQUEST_CODE = 548459
const val PERM_REQUEST_CODE = 66622635
class MainActivity : android.app.Activity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@ -28,13 +33,73 @@ class MainActivity : android.app.Activity() {
when (requestCode) {
CHOOSE_FILE_REQUEST_CODE -> {
if (resultCode == RESULT_OK) {
filePathCallback?.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(resultCode, data))
filePathCallback?.onReceiveValue(
WebChromeClient.FileChooserParams.parseResult(
resultCode,
data
)
)
filePathCallback = null
}
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
when (requestCode) {
PERM_REQUEST_CODE -> {
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
downloadFile()
} else {
Toast.makeText(this, "We cant make a save file without that permission", Toast.LENGTH_SHORT).show()
}
return
}
}
}
var filePathCallback: ValueCallback<Array<Uri>>? = null
var fileToDownload:String? = null
fun downloadFile(){
val url = fileToDownload ?: return
try{
if (!url.startsWith("data:")) {
Log.w("DL", "url ignored because it does not start with data:")
return
}
val sdf = SimpleDateFormat("yyyy-M-dd-hh-mm")
val currentDate = sdf.format(Date())
// Extract filename from contentDisposition if available
if (url.startsWith("data:application/json;base64,")) {
Log.d("DL", "saving application/json ")
val base64Data = url.substringAfterLast(',')
val decodedBytes =
android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
val jsonData = String(decodedBytes);
val dir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val fileName = "breakout-71-save-$currentDate.b71"
val file = File(dir, fileName)
file.writeText(jsonData)
Toast.makeText(this, "Saved in $dir", Toast.LENGTH_LONG).show()
Log.d("DL", "finished saving application/json ")
} else if (url.startsWith("data:video/webm;base64,")) {
Log.d("DL", "saving video/webm ")
// TODO
Log.d("DL", "finished savign video/webm ")
} else {
Log.w("DL", "unexpected type " + url)
}
}catch (e:Exception){
Log.e("DL", "Error ${e.message}")
Toast.makeText(this, "Error ${e.message}", Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE);
@ -46,8 +111,9 @@ class MainActivity : android.app.Activity() {
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.setSupportZoom(false)
webView.loadUrl("file:///android_asset/index.html?isInWebView=true")
webView.loadUrl("file:///android_asset/index.html?isInWebView=true")
val activity=this;
webView.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
@ -57,43 +123,35 @@ class MainActivity : android.app.Activity() {
)
return true
}
override fun onShowFileChooser(webView: WebView?, filePathCallback: ValueCallback<Array<Uri>>?, fileChooserParams: FileChooserParams?): Boolean {
startActivityForResult(fileChooserParams?.createIntent(), CHOOSE_FILE_REQUEST_CODE)
this@MainActivity.filePathCallback = filePathCallback
return true
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
try{
startActivityForResult(fileChooserParams?.createIntent(), CHOOSE_FILE_REQUEST_CODE)
this@MainActivity.filePathCallback = filePathCallback
return true
}catch (e:Exception){
Log.e("DL", "Error ${e.message}")
Toast.makeText(activity, "Error ${e.message}", Toast.LENGTH_LONG).show()
return false
}
}
}
webView.setDownloadListener(DownloadListener { url, userAgent, contentDisposition, mimetype, contentLength ->
if (!url.startsWith("data:")) {
Log.w("DL","url ignored because it does not start with data:")
return@DownloadListener
}
val sdf = SimpleDateFormat("yyyy-M-dd-hh-mm")
val currentDate = sdf.format(Date())
// Extract filename from contentDisposition if available
if (url.startsWith("data:application/json;base64,")) {
Log.d("DL","saving application/json ")
val base64Data = url.substringAfterLast(',')
val decodedBytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
val jsonData = String(decodedBytes)
;
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val fileName = "breakout-71-save-$currentDate.b71"
val file = File(dir, fileName)
file.writeText(jsonData)
Toast.makeText(this, "Saved in $dir", Toast. LENGTH_LONG).show()
Log.d("DL","finished saving application/json ")
}else if (url.startsWith("data:video/webm;base64,")){
Log.d("DL","saving video/webm ")
// TODO
Log.d("DL","finished savign video/webm ")
fileToDownload = url
if (activity.checkSelfPermission( android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
activity.requestPermissions(arrayOf( android.Manifest.permission.WRITE_EXTERNAL_STORAGE), PERM_REQUEST_CODE)
}else{
Log.w("DL","unexpected type "+url)
downloadFile()
}
})

View file

@ -1,13 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View file

@ -1,19 +1,9 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
<include domain="sharedpref"/>
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
<include domain="sharedpref"/>
</device-transfer>
-->
</data-extraction-rules>

View file

@ -5,7 +5,6 @@
defaultVersionCode=$(($(date +%s) / 60))
versionCode=${1:-$defaultVersionCode}
# TODO crash without app version
source ~/.nvm/nvm.sh;
@ -17,13 +16,6 @@ if [[ $(node --version) != v21* ]]; then
exit 1
fi
if grep -rE "T[O]DO|F[I]XME|console\.log" src
then
echo "You have left some TO""DO or logs"
exit 1
fi
set -e
set -x

View file

@ -1,5 +1,11 @@
#!/bin/bash
if grep -rE "T[O]DO|F[I]XME|console\.log" src
then
echo "You have left some TO""DO or logs"
exit 1
fi
set -e
set -x

33
dist/PWA/sw-b71.js vendored
View file

@ -1,33 +1,2 @@
// The version of the cache.
const VERSION = "29036807";
// The name of the cache
const CACHE_NAME = `breakout-71-${VERSION}`;
// The static resources that the app needs to function.
const APP_STATIC_RESOURCES = [
"/"
];
// On install, cache the static resources
self.addEventListener("install", (event)=>{
event.waitUntil((async ()=>{
const cache = await caches.open(CACHE_NAME);
cache.addAll(APP_STATIC_RESOURCES);
})());
});
// delete old caches on activate
self.addEventListener("activate", (event)=>{
event.waitUntil((async ()=>{
const names = await caches.keys();
await Promise.all(names.map((name)=>{
if (name !== CACHE_NAME) return caches.delete(name);
}));
await clients.claim();
})());
});
self.addEventListener("fetch", (event)=>{
if (event.request.mode === "navigate" && event.request.url.endsWith("/index.html?isPWA=true")) {
event.respondWith(caches.match("/"));
return;
}
});
function e(e,t,n,r,a,i,c){try{var o=e[i](c),u=o.value}catch(e){n(e);return}o.done?t(u):Promise.resolve(u).then(r,a)}function t(t){return function(){var n=this,r=arguments;return new Promise(function(a,i){var c=t.apply(n,r);function o(t){e(c,a,i,o,u,"next",t)}function u(t){e(c,a,i,o,u,"throw",t)}o(void 0)})}}function n(e,t){var n,r,a,i,c={label:0,sent:function(){if(1&a[0])throw a[1];return a[1]},trys:[],ops:[]};return i={next:o(0),throw:o(1),return:o(2)},"function"==typeof Symbol&&(i[Symbol.iterator]=function(){return this}),i;function o(i){return function(o){return function(i){if(n)throw TypeError("Generator is already executing.");for(;c;)try{if(n=1,r&&(a=2&i[0]?r.return:i[0]?r.throw||((a=r.return)&&a.call(r),0):r.next)&&!(a=a.call(r,i[1])).done)return a;switch(r=0,a&&(i=[2&i[0],a.value]),i[0]){case 0:case 1:a=i;break;case 4:return c.label++,{value:i[1],done:!1};case 5:c.label++,r=i[1],i=[0];continue;case 7:i=c.ops.pop(),c.trys.pop();continue;default:if(!(a=(a=c.trys).length>0&&a[a.length-1])&&(6===i[0]||2===i[0])){c=0;continue}if(3===i[0]&&(!a||i[1]>a[0]&&i[1]<a[3])){c.label=i[1];break}if(6===i[0]&&c.label<a[1]){c.label=a[1],a=i;break}if(a&&c.label<a[2]){c.label=a[2],c.ops.push(i);break}a[2]&&c.ops.pop(),c.trys.pop();continue}i=t.call(e,c)}catch(e){i=[6,e],r=0}finally{n=a=0}if(5&i[0])throw i[1];return{value:i[0]?i[1]:void 0,done:!0}}([i,o])}}}var r="breakout-71-".concat("29038230"),a=["/"];self.addEventListener("install",function(e){e.waitUntil(t(function(){return n(this,function(e){switch(e.label){case 0:return[4,caches.open(r)];case 1:return e.sent().addAll(a),[2]}})})())}),self.addEventListener("activate",function(e){e.waitUntil(t(function(){return n(this,function(e){switch(e.label){case 0:return[4,caches.keys()];case 1:return[4,Promise.all(e.sent().map(function(e){if(e!==r)return caches.delete(e)}))];case 2:return e.sent(),[4,clients.claim()];case 3:return e.sent(),[2]}})})())}),self.addEventListener("fetch",function(e){if("navigate"===e.request.mode&&e.request.url.endsWith("/index.html?isPWA=true")){e.respondWith(caches.match("/"));return}});
//# sourceMappingURL=sw-b71.js.map

File diff suppressed because one or more lines are too long

3849
dist/index.html vendored

File diff suppressed because one or more lines are too long

19
package-lock.json generated
View file

@ -25,6 +25,7 @@
},
"devDependencies": {
"@parcel/optimizer-data-url": "^2.13.3",
"@parcel/packager-raw-url": "^2.13.3",
"@parcel/transformer-inline-string": "^2.13.3",
"@parcel/transformer-less": "^2.13.3",
"@parcel/transformer-webmanifest": "^2.13.3",
@ -1669,6 +1670,24 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/packager-raw-url": {
"version": "2.13.3",
"resolved": "https://registry.npmjs.org/@parcel/packager-raw-url/-/packager-raw-url-2.13.3.tgz",
"integrity": "sha512-Dc8WeVagLGEUzVP4FqJBljXN59XSkvLoZaHeysvN9P33eznocrhIvc9T/OAQjOmsCj18X8jwxm0dIE7LNJbVCA==",
"dev": true,
"dependencies": {
"@parcel/plugin": "2.13.3",
"@parcel/utils": "2.13.3"
},
"engines": {
"node": ">= 16.0.0",
"parcel": "^2.13.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/packager-svg": {
"version": "2.13.3",
"resolved": "https://registry.npmjs.org/@parcel/packager-svg/-/packager-svg-2.13.3.tgz",

View file

@ -28,6 +28,7 @@
},
"devDependencies": {
"@parcel/optimizer-data-url": "^2.13.3",
"@parcel/packager-raw-url": "^2.13.3",
"@parcel/transformer-inline-string": "^2.13.3",
"@parcel/transformer-less": "^2.13.3",
"@parcel/transformer-webmanifest": "^2.13.3",

View file

@ -1,5 +1,5 @@
// The version of the cache.
const VERSION = "29036807";
const VERSION = "29038230";
// The name of the cache
const CACHE_NAME = `breakout-71-${VERSION}`;

View file

@ -1 +1 @@
"29036807"
"29038230"

View file

@ -3,12 +3,15 @@ import {
Ball,
Coin,
GameState,
LightFlash,
OptionId,
ParticleFlash,
PerkId,
RunParams,
TextFlash,
Upgrade,
} from "./types";
import {getAudioContext, playPendingSounds} from "./sounds";
import { getAudioContext, playPendingSounds } from "./sounds";
import {
currentLevelInfo,
getRowColIndex,
@ -20,6 +23,8 @@ import "./PWA/sw_loader";
import { getCurrentLang, t } from "./i18n/i18n";
import { getSettingValue, getTotalScore, setSettingValue } from "./settings";
import {
empty,
forEachLiveOne,
gameStateTick,
normalizeGameState,
pickRandomUpgrades,
@ -51,11 +56,12 @@ import {
closeModal,
} from "./asyncAlert";
import { isOptionOn, options, toggleOption } from "./options";
import {hashCode} from "./getLevelBackground";
import { hashCode } from "./getLevelBackground";
export function play() {
if (gameState.running) return;
gameState.running = true;
gameState.ballStickToPuck = false;
startRecordingGame(gameState);
getAudioContext()?.resume();
@ -95,6 +101,10 @@ export function pause(playerAskedForPause: boolean) {
}
export const fitSize = () => {
const past_off = gameState.offsetXRoundedDown,
past_width = gameState.gameZoneWidthRoundedUp,
past_heigh = gameState.gameZoneHeight;
const { width, height } = gameCanvas.getBoundingClientRect();
gameState.canvasWidth = width;
gameState.canvasHeight = height;
@ -123,10 +133,27 @@ export const fitSize = () => {
backgroundCanvas.title = "resized";
// Ensure puck stays within bounds
setMousePos(gameState, gameState.puckPosition);
gameState.coins = [];
gameState.flashes = [];
function mapXY(item: ParticleFlash | TextFlash | LightFlash) {
item.x =
gameState.offsetXRoundedDown +
((item.x - past_off) / past_width) * gameState.gameZoneWidthRoundedUp;
item.y = (item.y / past_heigh) * gameState.gameZoneHeight;
}
function mapXYPastCoord(coin: Coin | Ball) {
coin.x =
gameState.offsetXRoundedDown +
((coin.x - past_off) / past_width) * gameState.gameZoneWidthRoundedUp;
coin.y = (coin.y / past_heigh) * gameState.gameZoneHeight;
coin.previousX = coin.x;
coin.previousY = coin.y;
}
gameState.balls.forEach(mapXYPastCoord);
forEachLiveOne(gameState.coins, mapXYPastCoord);
forEachLiveOne(gameState.particles, mapXY);
forEachLiveOne(gameState.texts, mapXY);
forEachLiveOne(gameState.lights, mapXY);
pause(true);
putBallsAtPuck(gameState);
// For safari mobile https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
document.documentElement.style.setProperty(
"--vh",
@ -273,7 +300,9 @@ gameCanvas.addEventListener("mousemove", (e) => {
gameCanvas.addEventListener("touchstart", (e) => {
e.preventDefault();
if (!e.touches?.length) return;
setMousePos(gameState, e.touches[0].pageX);
normalizeGameState(gameState);
play();
});
gameCanvas.addEventListener("touchend", (e) => {
@ -442,7 +471,6 @@ export function tick() {
gameState.puckPosition + gameState.keyboardPuckSpeed,
);
}
normalizeGameState(gameState);
if (gameState.running) {
@ -457,8 +485,8 @@ export function tick() {
if (gameState.running) {
recordOneFrame(gameState);
}
if(isOptionOn('sound') ){
playPendingSounds(gameState)
if (isOptionOn("sound")) {
playPendingSounds(gameState);
}
requestAnimationFrame(tick);
}
@ -514,10 +542,13 @@ async function openScorePanel() {
}
}
document.getElementById("menu")?.addEventListener("click", (e) => {
e.preventDefault();
openSettingsPanel();
});
(document.getElementById("menu") as HTMLButtonElement).addEventListener(
"click",
(e) => {
e.preventDefault();
openSettingsPanel();
},
);
async function openSettingsPanel() {
pause(true);
@ -665,7 +696,7 @@ async function openSettingsPanel() {
localStorageContent[key] = value;
}
const signedPayload=JSON.stringify(localStorageContent)
const signedPayload = JSON.stringify(localStorageContent);
const dlLink = document.createElement("a");
dlLink.setAttribute(
@ -676,7 +707,10 @@ async function openSettingsPanel() {
fileType: "B71-save-file",
appVersion,
signedPayload,
key: hashCode('Security by obscurity, but really the game is oss so eh'+signedPayload)
key: hashCode(
"Security by obscurity, but really the game is oss so eh" +
signedPayload,
),
}),
),
);
@ -727,7 +761,8 @@ async function openSettingsPanel() {
const {
fileType,
appVersion: fileVersion,
signedPayload,key
signedPayload,
key,
} = JSON.parse(content);
if (fileType !== "B71-save-file")
throw new Error("Not a B71 save file");
@ -738,11 +773,17 @@ async function openSettingsPanel() {
" or newer.",
);
if(key!== hashCode('Security by obscurity, but really the game is oss so eh'+signedPayload)){
throw new Error("Key does not match content.")
if (
key !==
hashCode(
"Security by obscurity, but really the game is oss so eh" +
signedPayload,
)
) {
throw new Error("Key does not match content.");
}
const localStorageContent=JSON.parse(signedPayload)
const localStorageContent = JSON.parse(signedPayload);
localStorage.clear();
for (let key in localStorageContent) {
localStorage.setItem(key, localStorageContent[key]);
@ -982,4 +1023,4 @@ tick();
// @ts-ignore
// window.stressTest= ()=>restart({level:'Shark',perks:{base_combo:100, pierce:10, multiball:8}})
window.stressTest = () =>
restart({ level: "Shark", perks: { sapper: 2, pierce: 10, multiball: 3 } });
restart({ level: "Bird", perks: { sapper: 2, pierce: 10, multiball: 3 } });

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import {Ball, GameState, PerkId, PerksMap} from "./types";
import {icons, upgrades} from "./loadGameData";
import { Ball, GameState, PerkId, PerksMap } from "./types";
import { icons, upgrades } from "./loadGameData";
export function getMajorityValue(arr: string[]): string {
const count: { [k: string]: number } = {};
@ -101,16 +101,16 @@ export function distanceBetween(
}
export function defaultSounds() {
return {
aboutToPlaySound: {
wallBeep: {vol: 0, x: 0},
comboIncreaseMaybe: {vol: 0, x: 0},
comboDecrease: {vol: 0, x: 0},
coinBounce: {vol: 0, x: 0},
explode: {vol: 0, x: 0},
lifeLost: {vol: 0, x: 0},
coinCatch: {vol: 0, x: 0},
colorChange: {vol: 0, x: 0},
}
}
return {
aboutToPlaySound: {
wallBeep: { vol: 0, x: 0 },
comboIncreaseMaybe: { vol: 0, x: 0 },
comboDecrease: { vol: 0, x: 0 },
coinBounce: { vol: 0, x: 0 },
explode: { vol: 0, x: 0 },
lifeLost: { vol: 0, x: 0 },
coinCatch: { vol: 0, x: 0 },
colorChange: { vol: 0, x: 0 },
},
};
}

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<babeledit_project be_version="5.3.0" version="1.3">
<babeledit_project be_version="5.2.0" version="1.3">
<!--
BabelEdit project file
@ -3353,7 +3353,6 @@
</package_node>
</children>
</folder_node>
<embedded_source_texts>false</embedded_source_texts>
<isTemplateProject>false</isTemplateProject>
<languages>
<language>

View file

@ -39,7 +39,7 @@
"main_menu.basic_help": "Fewer particles and flashes, better performance.",
"main_menu.download_save_file": "Download save file",
"main_menu.download_save_file_help": "Get a transferable .b71 file with your score and stats",
"main_menu.footer_html": " <p> <span>Made in France by <a href=\"https://lecaro.me\">Renan LE CARO</a>.</span> \n <a href=\"https://breakout.lecaro.me/privacy.html\" target=\"_blank\">Privacy Policy</a>\n <a href=\"https://f-droid.org/en/packages/me.lecaro.breakout/\" target=\"_blank\">F-Droid</a>\n <a href=\"https://play.google.com/store/apps/details?id=me.lecaro.breakout\" target=\"_blank\">Google Play</a>\n <a href=\"https://renanlecaro.itch.io/breakout71\" target=\"_blank\">itch.io</a> \n <a href=\"https://gitlab.com/lecarore/breakout71\" target=\"_blank\">Gitlab</a>\n <a href=\"https://breakout.lecaro.me/\" target=\"_blank\">Web version</a>\n <a href=\"https://news.ycombinator.com/item?id=43183131\" target=\"_blank\">HackerNews</a>\n <span>v.{{appVersion}}</span></p>",
"main_menu.footer_html": "<p> \n<span>Made in France by <a href=\"https://lecaro.me\">Renan LE CARO</a>.</span> \n<a href=\"https://paypal.me/renanlecaro\" target=\"_blank\">Donate</a>\n<a href=\"https://discord.gg/DZSPqyJkwP\" target=\"_blank\">Discord</a>\n<a href=\"https://f-droid.org/en/packages/me.lecaro.breakout/\" target=\"_blank\">F-Droid</a>\n<a href=\"https://play.google.com/store/apps/details?id=me.lecaro.breakout\" target=\"_blank\">Google Play</a>\n<a href=\"https://renanlecaro.itch.io/breakout71\" target=\"_blank\">itch.io</a> \n<a href=\"https://gitlab.com/lecarore/breakout71\" target=\"_blank\">Gitlab</a>\n<a href=\"https://breakout.lecaro.me/\" target=\"_blank\">Web version</a>\n<a href=\"https://news.ycombinator.com/item?id=43183131\" target=\"_blank\">HackerNews</a>\n<a href=\"https://breakout.lecaro.me/privacy.html\" target=\"_blank\">Privacy Policy</a>\n<span>v.{{appVersion}}</span>\n</p>\n",
"main_menu.fullscreen": "Fullscreen",
"main_menu.fullscreen_exit": "Exit Fullscreen",
"main_menu.fullscreen_exit_help": "Might not work on some machines",

View file

@ -39,7 +39,7 @@
"main_menu.basic_help": "Moins de particules et effets, meilleures performances.",
"main_menu.download_save_file": "Sauvegarder mes progrès",
"main_menu.download_save_file_help": "Obtenir un fichier de sauvegarde .b71 transférable",
"main_menu.footer_html": " <p> <span>Programmé en France par <a href=\"https://lecaro.me\">Renan LE CARO</a>.</span> <a href=\"https://breakout.lecaro.me/privacy.html\" target=\"_blank\">Politique de confidentialité</a> <a href=\"https://f-droid.org/en/packages/me.lecaro.breakout/\" target=\"_blank\">F-Droid</a> <a href=\"https://play.google.com/store/apps/details?id=me.lecaro.breakout\" target=\"_blank\">Google Play</a> <a href=\"https://renanlecaro.itch.io/breakout71\" target=\"_blank\">itch.io</a> <a href=\"https://gitlab.com/lecarore/breakout71\" target=\"_blank\">Gitlab</a> <a href=\"https://breakout.lecaro.me/\" target=\"_blank\">Version web</a> <a href=\"https://news.ycombinator.com/item?id=43183131\" target=\"_blank\">HackerNews</a> <span>v.{{appVersion}}</span></p>",
"main_menu.footer_html": " <p> \n<span>Programmé en France par <a href=\"https://lecaro.me\">Renan LE CARO</a>.</span>\n<a href=\"https://paypal.me/renanlecaro\" target=\"_blank\">Donner</a>\n<a href=\"https://discord.gg/DZSPqyJkwP\" target=\"_blank\">Discord</a>\n<a href=\"https://f-droid.org/en/packages/me.lecaro.breakout/\" target=\"_blank\">F-Droid</a>\n<a href=\"https://play.google.com/store/apps/details?id=me.lecaro.breakout\" target=\"_blank\">Google Play</a>\n<a href=\"https://renanlecaro.itch.io/breakout71\" target=\"_blank\">itch.io</a>\n<a href=\"https://gitlab.com/lecarore/breakout71\" target=\"_blank\">Gitlab</a>\n<a href=\"https://breakout.lecaro.me/\" target=\"_blank\">Version web</a>\n<a href=\"https://news.ycombinator.com/item?id=43183131\" target=\"_blank\">HackerNews</a>\n<a href=\"https://breakout.lecaro.me/privacy.html\" target=\"_blank\">Politique de confidentialité</a> \n<span>v.{{appVersion}}</span>\n</p>",
"main_menu.fullscreen": "Plein écran",
"main_menu.fullscreen_exit": "Quitter le plein écran",
"main_menu.fullscreen_exit_help": "Peut ne pas fonctionner sur certaines machines",

View file

@ -1,10 +1,14 @@
import {GameState, RunParams} from "./types";
import {getTotalScore} from "./settings";
import {allLevels, upgrades} from "./loadGameData";
import {defaultSounds, getPossibleUpgrades, makeEmptyPerksMap, sumOfKeys,} from "./game_utils";
import {dontOfferTooSoon, resetBalls} from "./gameStateMutators";
import {isOptionOn} from "./options";
import { GameState, RunParams } from "./types";
import { getTotalScore } from "./settings";
import { allLevels, upgrades } from "./loadGameData";
import {
defaultSounds,
getPossibleUpgrades,
makeEmptyPerksMap,
sumOfKeys,
} from "./game_utils";
import { dontOfferTooSoon, resetBalls } from "./gameStateMutators";
import { isOptionOn } from "./options";
export function newGameState(params: RunParams): GameState {
const totalScoreAtRunStart = getTotalScore();
@ -33,6 +37,7 @@ export function newGameState(params: RunParams): GameState {
combo: 1,
gridSize: 12,
running: false,
ballStickToPuck: true,
puckPosition: 400,
pauseTimeout: null,
canvasWidth: 0,
@ -50,10 +55,10 @@ export function newGameState(params: RunParams): GameState {
balls: [],
ballsColor: "white",
bricks: [],
lights: {indexMin:0,list:[]},
particles: {indexMin:0,list:[]},
texts: {indexMin:0,list:[]},
coins: {indexMin:0,list:[]},
lights: { indexMin: 0, total: 0, list: [] },
particles: { indexMin: 0, total: 0, list: [] },
texts: { indexMin: 0, total: 0, list: [] },
coins: { indexMin: 0, total: 0, list: [] },
levelStartScore: 0,
levelMisses: 0,
levelSpawnedCoins: 0,
@ -90,7 +95,7 @@ export function newGameState(params: RunParams): GameState {
levelWallBounces: 0,
needsRender: true,
autoCleanUses: 0,
...defaultSounds()
...defaultSounds(),
};
resetBalls(gameState);
@ -109,4 +114,3 @@ export function newGameState(params: RunParams): GameState {
}
return gameState;
}

View file

@ -323,12 +323,12 @@ export const rawUpgrades = [
giftable: false,
id: "sturdy_bricks",
max: 4,
name: t("upgrades.telekinesis.name"),
name: t("upgrades.sturdy_bricks.name"),
help: (lvl: number) =>
lvl == 1
? t("upgrades.telekinesis.help")
: t("upgrades.telekinesis.help_plural"),
fullHelp: t("upgrades.telekinesis.fullHelp"),
? t("upgrades.sturdy_bricks.help")
: t("upgrades.sturdy_bricks.help_plural"),
fullHelp: t("upgrades.sturdy_bricks.fullHelp"),
},
{
requires: "",

View file

@ -1,4 +1,4 @@
import { baseCombo } from "./gameStateMutators";
import { baseCombo, forEachLiveOne, liveCount } from "./gameStateMutators";
import {
brickCenterX,
brickCenterY,
@ -54,9 +54,8 @@ export function render(gameState: GameState) {
ctx.globalCompositeOperation = "screen";
ctx.globalAlpha = 0.6;
gameState.coins.forEach((coin) => {
if (!coin.destroyed)
drawFuzzyBall(ctx, coin.color, gameState.coinSize * 2, coin.x, coin.y);
forEachLiveOne(gameState.coins, (coin) => {
drawFuzzyBall(ctx, coin.color, gameState.coinSize * 2, coin.x, coin.y);
});
gameState.balls.forEach((ball) => {
drawFuzzyBall(
@ -81,17 +80,19 @@ export function render(gameState: GameState) {
);
});
ctx.globalAlpha = 1;
gameState.flashes.forEach((flash) => {
const { x, y, time, color, size, type, duration } = flash;
forEachLiveOne(gameState.lights, (flash) => {
const { x, y, time, color, size, duration } = flash;
const elapsed = gameState.levelTime - time;
ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
if (type === "ball") {
drawFuzzyBall(ctx, color, size, x, y);
}
if (type === "particle") {
drawFuzzyBall(ctx, color, size * 3, x, y);
}
drawFuzzyBall(ctx, color, size, x, y);
});
forEachLiveOne(gameState.particles, (flash) => {
const { x, y, time, color, size, duration } = flash;
const elapsed = gameState.levelTime - time;
ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
drawFuzzyBall(ctx, color, size * 3, x, y);
});
// Decides how brights the bg black parts can get
ctx.globalAlpha = 0.2;
ctx.globalCompositeOperation = "multiply";
@ -128,14 +129,11 @@ export function render(gameState: GameState) {
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = level.color || "#000";
ctx.fillRect(0, 0, width, height);
gameState.flashes.forEach((flash) => {
const { x, y, time, color, size, type, duration } = flash;
forEachLiveOne(gameState.particles, (flash) => {
const { x, y, time, color, size, duration } = flash;
const elapsed = gameState.levelTime - time;
ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
if (type === "particle") {
drawBall(ctx, color, size, x, y);
}
drawBall(ctx, color, size, x, y);
});
}
@ -161,27 +159,24 @@ export function render(gameState: GameState) {
}
// Coins
ctx.globalAlpha = 1;
gameState.coins.forEach((coin) => {
if (!coin.destroyed) {
ctx.globalCompositeOperation =
coin.color === "gold" || level.color ? "source-over" : "screen";
drawCoin(
ctx,
coin.color,
coin.size,
coin.x,
coin.y,
level.color || "black",
coin.a,
);
}
forEachLiveOne(gameState.coins, (coin) => {
ctx.globalCompositeOperation =
coin.color === "gold" || level.color ? "source-over" : "screen";
drawCoin(
ctx,
coin.color,
coin.size,
coin.x,
coin.y,
level.color || "black",
coin.a,
);
});
// Black shadow around balls
if (!isOptionOn("basic")) {
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = Math.min(0.8, gameState.coins.length / 20);
ctx.globalAlpha = Math.min(0.8, liveCount(gameState.coins) / 20);
gameState.balls.forEach((ball) => {
drawBall(
ctx,
@ -197,22 +192,21 @@ export function render(gameState: GameState) {
renderAllBricks();
ctx.globalCompositeOperation = "screen";
gameState.flashes = gameState.flashes.filter(
(f) => gameState.levelTime - f.time < f.duration && !f.destroyed,
);
gameState.flashes.forEach((flash) => {
const { x, y, time, color, size, type, duration } = flash;
forEachLiveOne(gameState.texts, (flash) => {
const { x, y, time, color, size, duration } = flash;
const elapsed = gameState.levelTime - time;
ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2));
if (type === "text") {
ctx.globalCompositeOperation = "source-over";
drawText(ctx, flash.text, color, size, x, y - elapsed / 10);
} else if (type === "particle") {
ctx.globalCompositeOperation = "screen";
drawBall(ctx, color, size, x, y);
drawFuzzyBall(ctx, color, size, x, y);
}
ctx.globalCompositeOperation = "source-over";
drawText(ctx, flash.text, color, size, x, y - elapsed / 10);
});
forEachLiveOne(gameState.particles, (particle) => {
const { x, y, time, color, size, duration } = particle;
const elapsed = gameState.levelTime - time;
ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2));
ctx.globalCompositeOperation = "screen";
drawBall(ctx, color, size, x, y);
drawFuzzyBall(ctx, color, size, x, y);
});
if (gameState.perks.extra_life) {

View file

@ -1,31 +1,36 @@
import { isOptionOn } from "./options";
import {GameState} from "./types";
import { GameState } from "./types";
let lastPlay = Date.now()
let lastPlay = Date.now();
export function playPendingSounds(gameState:GameState){
if(lastPlay>Date.now()-60){
return
export function playPendingSounds(gameState: GameState) {
if (lastPlay > Date.now() - 60) {
return;
}
lastPlay=Date.now()
for(let key in gameState.aboutToPlaySound){
const soundName = key as keyof GameState["aboutToPlaySound"]
const ex = gameState.aboutToPlaySound[soundName] as {vol:number, x:number}
if(ex.vol){
sounds[soundName](Math.min(2,ex.vol),pixelsToPan(gameState, ex.x), gameState.combo)
ex.vol=0
lastPlay = Date.now();
for (let key in gameState.aboutToPlaySound) {
const soundName = key as keyof GameState["aboutToPlaySound"];
const ex = gameState.aboutToPlaySound[soundName] as {
vol: number;
x: number;
};
if (ex.vol) {
sounds[soundName](
Math.min(2, ex.vol),
pixelsToPan(gameState, ex.x),
gameState.combo,
);
ex.vol = 0;
}
}
}
export const sounds = {
wallBeep: (vol:number, pan: number, combo:number) => {
wallBeep: (vol: number, pan: number, combo: number) => {
if (!isOptionOn("sound")) return;
createSingleBounceSound(800, pan, vol);
},
comboIncreaseMaybe: ( volume: number,pan: number,combo: number, ) => {
comboIncreaseMaybe: (volume: number, pan: number, combo: number) => {
if (!isOptionOn("sound")) return;
let delta = 0;
if (!isNaN(lastComboPlayed)) {
@ -36,28 +41,28 @@ export const sounds = {
lastComboPlayed = combo;
},
comboDecrease(volume: number,pan: number,combo: number) {
comboDecrease(volume: number, pan: number, combo: number) {
if (!isOptionOn("sound")) return;
playShepard(-1, pan, volume);
},
coinBounce: (volume: number,pan: number,combo: number) => {
coinBounce: (volume: number, pan: number, combo: number) => {
if (!isOptionOn("sound")) return;
createSingleBounceSound(1200, pan, volume, 0.1, "triangle");
},
explode: (volume: number,pan: number,combo: number) => {
explode: (volume: number, pan: number, combo: number) => {
if (!isOptionOn("sound")) return;
createExplosionSound(pan);
},
lifeLost(volume: number,pan: number,combo: number) {
lifeLost(volume: number, pan: number, combo: number) {
if (!isOptionOn("sound")) return;
createShatteredGlassSound(pan);
},
coinCatch(volume: number,pan: number,combo: number) {
coinCatch(volume: number, pan: number, combo: number) {
if (!isOptionOn("sound")) return;
createSingleBounceSound(900, (pan), volume, 0.1, "triangle");
createSingleBounceSound(900, pan, volume, 0.1, "triangle");
},
colorChange(volume: number,pan: number,combo: number) {
colorChange(volume: number, pan: number, combo: number) {
createSingleBounceSound(400, pan, volume, 0.5, "sine");
createSingleBounceSound(800, pan, volume * 0.5, 0.2, "square");
},
@ -175,7 +180,7 @@ function createExplosionSound(pan = 0.5) {
noiseSource.stop(context.currentTime + 1);
}
function pixelsToPan(gameState:GameState, pan: number) {
function pixelsToPan(gameState: GameState, pan: number) {
return Math.max(
0,
Math.min(

46
src/types.d.ts vendored
View file

@ -113,22 +113,22 @@ interface BaseFlash {
}
interface ParticleFlash extends BaseFlash {
type: "particle";
// type: "particle";
vx: number;
vy: number;
ethereal: boolean;
}
interface TextFlash extends BaseFlash {
type: "text";
// type: "text";
text: string;
}
interface BallFlash extends BaseFlash {
type: "ball";
interface LightFlash extends BaseFlash {
// type: "ball";
}
export type Flash = ParticleFlash | TextFlash | BallFlash;
export type Flash = ParticleFlash | TextFlash | LightFlash;
export type RunStats = {
started: number;
@ -153,9 +153,10 @@ export type PerksMap = {
// TODO ensure T has a destroyed;boolean field
export type ReusableArray<T> = {
// All items below that index should not be destroyed
indexMin:number;
list:T[]
}
indexMin: number;
total: number;
list: T[];
};
export type RunHistoryItem = RunStats & {
perks?: PerksMap;
@ -197,6 +198,7 @@ export type GameState = {
combo: number;
// Whether the game is running or paused
running: boolean;
ballStickToPuck: boolean;
// Whether the game should be re-rendered once even if not running
needsRender: boolean;
// Position of the center of the puck on the canvas in pixels, from the left of the canvas.
@ -220,11 +222,9 @@ export type GameState = {
// Array of bricks to display. 'black' means bomb. '' means no brick.
bricks: colorString[];
particles: ReusableArray<ParticleFlash>
texts: ReusableArray<TextFlash>
lights: ReusableArray<BallFlash>
particles: ReusableArray<ParticleFlash>;
texts: ReusableArray<TextFlash>;
lights: ReusableArray<LightFlash>;
coins: ReusableArray<Coin>;
levelStartScore: number;
levelMisses: number;
@ -248,16 +248,16 @@ export type GameState = {
levelTime: number;
levelWallBounces: number;
autoCleanUses: number;
aboutToPlaySound:{
wallBeep:{vol:number, x:number},
comboIncreaseMaybe:{vol:number, x:number},
comboDecrease:{vol:number, x:number},
coinBounce:{vol:number, x:number},
explode:{vol:number, x:number},
lifeLost:{vol:number, x:number},
coinCatch:{vol:number, x:number},
colorChange:{vol:number, x:number},
}
aboutToPlaySound: {
wallBeep: { vol: number; x: number };
comboIncreaseMaybe: { vol: number; x: number };
comboDecrease: { vol: number; x: number };
coinBounce: { vol: number; x: number };
explode: { vol: number; x: number };
lifeLost: { vol: number; x: number };
coinCatch: { vol: number; x: number };
colorChange: { vol: number; x: number };
};
};
export type RunParams = {