diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000..0b412a1 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,17 @@ + + + + + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/package-lock.json b/package-lock.json index a1ea7f7..815847a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beaming", - "version": "0.4.2", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beaming", - "version": "0.4.2", + "version": "0.5.0", "license": "CC BY-NC 4.0", "dependencies": { "@fontsource-variable/noto-sans-mono": "^5.2.10", diff --git a/package.json b/package.json index a4e0a0b..7eec63b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "beaming", "author": "Kyle Florence", "description": "A browser-based puzzle game that involves directing beams through a hexagonal grid.", - "version": "0.4.4", + "version": "0.5.0", "license": "CC BY-NC 4.0", "main": "src/electron/main.js", "type": "module", diff --git a/src/components/editor.js b/src/components/editor.js index 49efcf0..bbb8484 100644 --- a/src/components/editor.js +++ b/src/components/editor.js @@ -12,6 +12,7 @@ import { Gutter } from './gutter' import { Phosphor } from './iconlib.js' import { Icon, Icons } from './icon.js' import { Game } from './game.js' +import { Achievements } from '../keys.js' const elements = Object.freeze({ cancel: document.getElementById('editor-cancel'), @@ -448,6 +449,7 @@ export class Editor { #updateConfiguration (state) { elements.configuration.value = JSON.stringify(state, null, 2) + window.electron?.steam.unlockAchievement(Achievements.Edit) this.#updatePlayUrl() } diff --git a/src/components/items/beam.js b/src/components/items/beam.js index e31c924..07bae27 100644 --- a/src/components/items/beam.js +++ b/src/components/items/beam.js @@ -13,6 +13,7 @@ import { Step, StepState } from '../step' import { Collision, CollisionMergeWith } from '../collision' import { Cache } from '../cache' import { Tile } from './tile.js' +import { Achievements } from '../../keys.js' export class Beam extends Item { done = false @@ -321,7 +322,10 @@ export class Beam extends Item { } else if (!isSameDirection) { // For a collision with self, the update at point of impact will occur on the next update loop. This results in // a better visualization of the collision which will result in an infinite looping animation. - setTimeout(() => this.#update(stepIndex), puzzle.getBeamsUpdateDelay()) + setTimeout(() => { + window.electron?.steam.unlockAchievement(Achievements.Infinity) + this.#update(stepIndex) + }, puzzle.getBeamsUpdateDelay()) } } diff --git a/src/components/puzzle.js b/src/components/puzzle.js index f3f4a34..c14aba2 100644 --- a/src/components/puzzle.js +++ b/src/components/puzzle.js @@ -32,6 +32,9 @@ import { Tile } from './items/tile.js' import { Storage } from './storage.js' import { Puzzles } from '../puzzles/index.js' import { debug } from './debug.js' +import { Achievements } from '../keys.js' + +const electron = window.electron const elements = Object.freeze({ canvas: document.getElementById('puzzle-canvas-wrapper'), @@ -715,6 +718,8 @@ export class Puzzle { return } + const id = this.state.getId() + this.solved = true this.updateSelectedTile(undefined) @@ -723,6 +728,8 @@ export class Puzzle { // Store the solution in cache this.state.setSolution(this.layout.tiles.filter(this.#mask.tileFilter)) + const solutions = State.addSolution(id, this.state.getSolution()) + const p = document.createElement('p') p.classList.add(Puzzle.ClassNames.Solved) p.textContent = 'Puzzle solved!' @@ -735,6 +742,19 @@ export class Puzzle { document.body.classList.add(Puzzle.Events.Solved) emitEvent(Puzzle.Events.Solved) + + if (electron) { + electron.steam.unlockAchievement(Achievements.FirstSolve) + + if (solutions.length > 5) { + electron.steam.unlockAchievement(Achievements.Hex) + } + + // TODO should probably come up with a more flexible system for this rather than hard coding it + if (this.state.getId() === '001') { + electron.steam.unlockAchievement(Achievements.Puzzle001) + } + } } #onStateUpdate (event) { diff --git a/src/components/settings.js b/src/components/settings.js index 6570fc0..22b2d6c 100644 --- a/src/components/settings.js +++ b/src/components/settings.js @@ -1,3 +1,3 @@ import './settings/cache.js' -import './settings/debug.js' import './settings/profile.js' +import './settings/troubleshooting.js' diff --git a/src/components/settings/debug.js b/src/components/settings/troubleshooting.js similarity index 100% rename from src/components/settings/debug.js rename to src/components/settings/troubleshooting.js diff --git a/src/components/state.js b/src/components/state.js index 391c7f2..3452542 100644 --- a/src/components/state.js +++ b/src/components/state.js @@ -278,6 +278,18 @@ export class State { this.#deltas.filter((delta, index) => index <= deltaIndex).forEach((delta) => this.#apply(delta)) } + static add (id) { + const ids = Array.from(new Set([...State.getIds(), id])) + Storage.set(State.key(State.CacheKeys.Ids), JSON.stringify(ids)) + return ids + } + + static addSolution (id, solution) { + const solutions = Array.from(new Set([...State.getSolutions(), solution.join(':')])) + Storage.set(State.key(id ?? State.getId(), State.CacheKeys.Solutions), JSON.stringify(solutions)) + return solutions + } + static clearCache (id) { Storage.delete(id === undefined ? id : State.key(id)) } @@ -406,6 +418,10 @@ export class State { return params.get(State.CacheKeys.Parent)?.split(',') ?? [] } + static getSolutions (id) { + return JSON.parse(Storage.get(State.key(id ?? State.getId(), State.CacheKeys.Solutions)) ?? '[]') + } + static resolve (id) { let values = [] @@ -512,7 +528,8 @@ export class State { Id: 'id', Ids: 'ids', Locked: 'locked', - Parent: 'parent' + Parent: 'parent', + Solutions: 'solutions' }) static ContextKeys = Object.freeze({ @@ -542,12 +559,6 @@ export class State { static toString = classToString('State') - static add (id) { - const ids = Array.from(new Set([...State.getIds(), id])) - Storage.set(State.key(State.CacheKeys.Ids), JSON.stringify(ids)) - return ids - } - static remove (id, context = State.getContext()) { const ids = State.getIds(context) const index = ids.indexOf(id) diff --git a/src/electron/channels.js b/src/electron/channels.js index ffa8aea..7ea2cc7 100644 --- a/src/electron/channels.js +++ b/src/electron/channels.js @@ -3,6 +3,7 @@ export default Object.freeze({ debug: 'debug', quit: 'quit', resizeWindow: 'resize-window', + steamAchievementUnlock: 'steam-achievement-unlock', storeDelete: 'store-delete', storeGet: 'store-get', storeSet: 'store-set', diff --git a/src/electron/main.js b/src/electron/main.js index 93c7963..f06361d 100644 --- a/src/electron/main.js +++ b/src/electron/main.js @@ -46,6 +46,10 @@ function createWindow () { window = new BrowserWindow(options) + if (store.get(Keys.enableSteamOverlay) === true) { + steam.setupOverlay(window) + } + if (windowType === Values.maximized) { window.maximize() } @@ -115,6 +119,12 @@ ipcMain.on(channels.resizeWindow, (event, value, settings) => { } }) +ipcMain.handle(channels.steamAchievementUnlock, (event, name) => { + steam.unlockAchievement(name).catch((reason) => { + console.error('Failed to unlock achievement', reason) + }) +}) + ipcMain.handle(channels.storeDelete, (event, key) => { if (key === undefined) { store.clear() @@ -136,10 +146,10 @@ ipcMain.handle(channels.storeSet, (event, key, value) => { }) app.whenReady().then(() => { - createWindow() - steam.setup() + createWindow() + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() diff --git a/src/electron/preload.js b/src/electron/preload.js index 30ecaf8..9e51d78 100644 --- a/src/electron/preload.js +++ b/src/electron/preload.js @@ -7,6 +7,12 @@ import { Keys } from '../keys.js' const electron = 'electron' const localStorage = window.localStorage +const steam = { + unlockAchievement: async function (name) { + return ipcRenderer.invoke(channels.steamAchievementUnlock, name) + } +} + const store = { delete: async function (key) { return ipcRenderer.invoke(channels.storeDelete, key) @@ -62,5 +68,6 @@ contextBridge.exposeInMainWorld(electron, { onWindowResized, quit, resizeWindow, + steam, store }) diff --git a/src/electron/settings/window.js b/src/electron/settings/window.js index 3b33ff3..887931a 100644 --- a/src/electron/settings/window.js +++ b/src/electron/settings/window.js @@ -69,3 +69,11 @@ electron.onWindowResized((bounds) => { windowWidth.value = bounds.width } }) + +const $steamOverlay = document.getElementById('settings-steam-overlay') +$steamOverlay.checked = localStorage.getItem(Keys.enableSteamOverlay) === 'true' + +$steamOverlay.addEventListener('change', () => { + localStorage.setItem(Keys.enableSteamOverlay, $steamOverlay.checked) + window.electron?.store.set(Keys.enableSteamOverlay, $steamOverlay.checked) +}) diff --git a/src/electron/steam.js b/src/electron/steam.js index a56bce9..13d3899 100644 --- a/src/electron/steam.js +++ b/src/electron/steam.js @@ -1,10 +1,12 @@ import { SteamworksSDK } from 'steamworks-ffi-node' +import { AchievementNames } from '../keys.js' export default class Steam { #interval #sdk appId = 4172230 + overlayEnabled = false constructor () { this.#sdk = SteamworksSDK.getInstance() @@ -15,9 +17,20 @@ export default class Steam { this.#interval = setInterval(this.#sdk.runCallbacks.bind(this.#sdk), 1000) } + this.overlayEnabled = this.#sdk.isOverlayAvailable() + console.debug(Steam.toString('setup'), this.#sdk.getStatus()) } + setupOverlay (window) { + if (!this.overlayEnabled) { + return + } + + this.#sdk.addElectronSteamOverlay(window) + console.debug(Steam.toString('addOverlay'), 'Steam overlay added') + } + teardown () { if (this.#interval === undefined) { return @@ -28,6 +41,20 @@ export default class Steam { this.#sdk.shutdown() } + async unlockAchievement (name) { + if (!AchievementNames.includes(name)) { + throw new Error(`Invalid achievement name: ${name}`) + } + + let unlocked = await this.#sdk.achievements.isAchievementUnlocked(name) + if (!unlocked) { + unlocked = await this.#sdk.achievements.unlockAchievement(name) + console.debug(Steam.toString('unlockAchievement'), name, unlocked) + } + + return unlocked + } + static toString () { return '[' + ['Steam', ...arguments].join(':') + ']' } diff --git a/src/index.html b/src/index.html index 4abcb04..bc7b846 100644 --- a/src/index.html +++ b/src/index.html @@ -423,7 +423,7 @@

Settings

-
+
Window
@@ -438,6 +438,8 @@

Settings

+ +
@@ -446,7 +448,10 @@

Settings

Found a bug? When reporting please include the share URL () along with your report. For some extra information on screen, you can enable debug mode below.

- +
+ + +
Cache diff --git a/src/keys.js b/src/keys.js index 1a37ae3..a40a2c5 100644 --- a/src/keys.js +++ b/src/keys.js @@ -6,8 +6,21 @@ function key () { return Array.from(arguments).join(':') } +// The values should correspond to the values defined in Steam Admin +// https://partner.steamgames.com/apps/achievements/4172230 +export const Achievements = Object.freeze({ + FirstSolve: 'ACH_FIRST', + Edit: 'ACH_EDIT', + Hex: 'ACH_HEX', + Infinity: 'ACH_INFINITY', + Puzzle001: 'ACH_PUZZLE_001' +}) + +export const AchievementNames = Object.values(Achievements) + export const Keys = Object.freeze({ debug: 'debug', + enableSteamOverlay: 'enable-steam-overlay', window: key(settings, window), windowHeight: key(settings, window, 'height'), windowWidth: key(settings, window, 'width') diff --git a/src/styles.css b/src/styles.css index ff30979..ec55402 100644 --- a/src/styles.css +++ b/src/styles.css @@ -952,11 +952,11 @@ dialog ul { margin-right: 1em; } -#settings-window { +.electron-only { display: none; } -.electron #settings-window { +.electron .electron-only { display: block; }