From b2eab855b0ff8450952c65e65e2b55bbb00ed5c4 Mon Sep 17 00:00:00 2001
From: Kyle Florence
Date: Tue, 12 May 2026 13:46:37 -0700
Subject: [PATCH] Add steam achievements integration. Fixes #90
---
build/entitlements.mac.plist | 17 ++++++++++++
package-lock.json | 4 +--
package.json | 2 +-
src/components/editor.js | 2 ++
src/components/items/beam.js | 6 ++++-
src/components/puzzle.js | 20 ++++++++++++++
src/components/settings.js | 2 +-
.../settings/{debug.js => troubleshooting.js} | 0
src/components/state.js | 25 ++++++++++++-----
src/electron/channels.js | 1 +
src/electron/main.js | 14 ++++++++--
src/electron/preload.js | 7 +++++
src/electron/settings/window.js | 8 ++++++
src/electron/steam.js | 27 +++++++++++++++++++
src/index.html | 9 +++++--
src/keys.js | 13 +++++++++
src/styles.css | 4 +--
17 files changed, 143 insertions(+), 18 deletions(-)
create mode 100644 build/entitlements.mac.plist
rename src/components/settings/{debug.js => troubleshooting.js} (100%)
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
-
+
+
@@ -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.
-
+
+
+
+
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;
}