Showing preview only (551K chars total). Download the full file or copy to clipboard to get everything.
Repository: xkcd/incredible
Branch: main
Commit: 3f8660708203
Files: 126
Total size: 515.2 KB
Directory structure:
gitextract_ufpta25o/
├── .gitignore
├── CHANGELOG.md
├── Gen/
│ └── Main.hs
├── LICENSE
├── README.md
├── api-spec.json
├── app/
│ └── Main.hs
├── client/
│ ├── babel.config.json
│ ├── comic.json
│ ├── eslint.config.js
│ ├── loaders/
│ │ └── comic-image-loader.js
│ ├── package.json
│ ├── prettier.config.mjs
│ ├── src/
│ │ ├── api.ts
│ │ ├── components/
│ │ │ ├── BallPitMechanism.tsx
│ │ │ ├── CenteredSlippyMap.tsx
│ │ │ ├── Comic.tsx
│ │ │ ├── ComicBrowseView.tsx
│ │ │ ├── ComicImage.tsx
│ │ │ ├── ComicPuzzleView.tsx
│ │ │ ├── DebugOverlay.tsx
│ │ │ ├── EditorTutorials.tsx
│ │ │ ├── FullscreenComicContainer.tsx
│ │ │ ├── InnerComicBorder.tsx
│ │ │ ├── LoadingSpinner.tsx
│ │ │ ├── MachineContext.tsx
│ │ │ ├── MachineTileContext.tsx
│ │ │ ├── MachineTileEditor.tsx
│ │ │ ├── MachineTilePlaceholder.tsx
│ │ │ ├── MetaMachineView.tsx
│ │ │ ├── NamePrompt.tsx
│ │ │ ├── PhysicsContext.tsx
│ │ │ ├── SwooshyDialog.tsx
│ │ │ ├── WidgetPalette.tsx
│ │ │ ├── constants.tsx
│ │ │ ├── moderation/
│ │ │ │ ├── BlueprintButton.tsx
│ │ │ │ ├── ContextGridForMachineAt.tsx
│ │ │ │ ├── LiveMachinePreview.tsx
│ │ │ │ ├── ModMachineTileView.tsx
│ │ │ │ ├── ModTileInputOutputView.tsx
│ │ │ │ ├── Moderator.tsx
│ │ │ │ ├── SelectTileForm.tsx
│ │ │ │ ├── interestingWeights.ts
│ │ │ │ ├── modTypes.d.ts
│ │ │ │ ├── modUtils.ts
│ │ │ │ └── moderatorClient.ts
│ │ │ ├── positionStyles.ts
│ │ │ ├── useLocationHashParams.tsx
│ │ │ ├── useMetaMachineClient.ts
│ │ │ └── widgets/
│ │ │ ├── Anvil.tsx
│ │ │ ├── AttractorRepulsor.tsx
│ │ │ ├── BallStand.tsx
│ │ │ ├── Balls.tsx
│ │ │ ├── Board.tsx
│ │ │ ├── Boat.tsx
│ │ │ ├── BottomChute.tsx
│ │ │ ├── BottomPit.tsx
│ │ │ ├── BottomTank.tsx
│ │ │ ├── Brick.tsx
│ │ │ ├── CatSwat.tsx
│ │ │ ├── CircleGauge.tsx
│ │ │ ├── Cup.tsx
│ │ │ ├── Cushion.tsx
│ │ │ ├── Fan.tsx
│ │ │ ├── Hammer.tsx
│ │ │ ├── Hook.tsx
│ │ │ ├── InputOutput.tsx
│ │ │ ├── LeftBumper.tsx
│ │ │ ├── MachineFrame.tsx
│ │ │ ├── OutputValidator.tsx
│ │ │ ├── Prism.tsx
│ │ │ ├── QuantumGate.tsx
│ │ │ ├── RightBumper.tsx
│ │ │ ├── RoundBumper.tsx
│ │ │ ├── SpawnInput.tsx
│ │ │ ├── Sticker.tsx
│ │ │ ├── Sword.tsx
│ │ │ ├── Wheel.tsx
│ │ │ ├── index.tsx
│ │ │ └── lib/
│ │ │ ├── ball.ts
│ │ │ └── lineCuboid.ts
│ │ ├── custom.d.ts
│ │ ├── generated/
│ │ │ └── api-spec.d.ts
│ │ ├── image.d.ts
│ │ ├── index.ejs
│ │ ├── index.tsx
│ │ ├── lib/
│ │ │ ├── coords.ts
│ │ │ ├── snapshot.tsx
│ │ │ ├── tiles.tsx
│ │ │ └── utils.ts
│ │ ├── page/
│ │ │ ├── demo-editor.tsx
│ │ │ ├── demo-map.tsx
│ │ │ ├── demo-viewer.tsx
│ │ │ ├── fixtures/
│ │ │ │ ├── demoMachine.tsx
│ │ │ │ └── emptyMachine.tsx
│ │ │ ├── moderator.tsx
│ │ │ └── page.ejs
│ │ └── types.ts
│ ├── tsconfig.json
│ └── webpack.config.js
├── config/
│ ├── incredible.toml
│ └── machine.json
├── docs/
│ └── Main.hs
├── flake.nix
├── incredible.cabal
├── nix/
│ ├── deploy.nix
│ ├── digital-ocean/
│ │ ├── digital-ocean-config.nix
│ │ ├── digital-ocean-custom-image.nix
│ │ └── make-single-disk-zfs-image.nix
│ ├── incredible-cfg.nix
│ ├── incredible-digital-ocean.nix
│ ├── incredible-frontend.nix
│ ├── incredible-qemu.nix
│ ├── incredible-server.nix
│ ├── profiles/
│ │ └── staging.nix
│ └── users/
│ └── deploy.nix
├── src/
│ └── Incredible/
│ ├── API.hs
│ ├── AntiEvil.hs
│ ├── App.hs
│ ├── Config.hs
│ ├── Data.hs
│ ├── DataStore/
│ │ ├── Memory.hs
│ │ └── Redis.hs
│ ├── DataStore.hs
│ └── Puzzle.hs
└── test/
└── Main.hs
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
dist
dist-*
cabal-dev
*.o
*.hi
*.hie
*.chi
*.chs.h
*.dyn_o
*.dyn_hi
.hpc
.hsenv
.cabal-sandbox/
cabal.sandbox.config
*.prof
*.aux
*.hp
*.eventlog
.stack-work/
cabal.project.local
cabal.project.local~
.HTF/
.ghc.environment.*
result
node_modules
client/built/
.vscode/tasks.json
*~
.direnv
================================================
FILE: CHANGELOG.md
================================================
# Revision history for incredible
## 0.1.0.0 -- YYYY-mm-dd
* First version. Released on an unsuspecting world.
================================================
FILE: Gen/Main.hs
================================================
module Main where
import Control.Monad
import Incredible.App (writePuzzleMachine)
import Incredible.Data
import Incredible.Puzzle
import Options.Applicative
import System.Random
import System.Random.Stateful
data GenConfig = GenConfig {
genSeed :: Maybe Int
, genTest :: Bool
} deriving (Eq, Ord, Show)
genOpts :: Parser GenConfig
genOpts = do
GenConfig <$> configSeed <*> configTest
where
configSeed = option auto $ mconcat [
long "seed"
, short 's'
, metavar "SEED"
, value Nothing
, help "Seed for the random number generator"
]
configTest = flag False True $ mconcat [
long "test"
, short 't'
, help "Generate a smaller test puzzle"
]
main :: IO ()
main = do
opts <- execParser $ info (genOpts <**> helper) fullDesc
seed <- case genSeed opts of
Just s -> pure s
Nothing -> randomIO
putStrLn $ "Using seed: " <> show seed
let
(label, puzzle) = if genTest opts then ("test-machine-", testPuzzle) else ("machine-", gamePuzzle)
fp = label <> show seed <> ".json"
gen <- newIOGenM $ mkStdGen seed
generated <- generatePuzzle gen puzzle
let m = mm generated
possible <- machineIsSolvable m
when (not possible) $ fail "Machine is not solvable"
writePuzzleMachine fp m
putStrLn "Machine written"
where
mm arr = MetaMachine arr (TileSize 740 740) 1000 mempty
================================================
FILE: LICENSE
================================================
Code is licensed as
Copyright (c) 2024, xkcd
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* Neither the name of xkcd nor the names of other
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Art is licensed under Creative Commons Attribution-NonCommercial 2.5 License.
================================================
FILE: README.md
================================================
# Incredible
[https://xkcd.com/2916](https://xkcd.com/2916)
## Development
There is a Nix shell defined in `flake.nix` that can be accessed with `nix develop`. This includes everything to build the client and the server.
### Frontend
The client is built with NodeJS 20.
A live development version can be started with `npm run start:dev` in `client/`.
Production JS can be built with `nix build .#incredible-client` or `npm run build` in `client/`.
### Backend
The server is built with GHC 9.6.4.
To build and run the webserver with Cabal, `cabal run incredible-server` or with Nix, `nix run .#incredible:exe:incredible-server`.
There is also an executable for generating puzzles, `cabal run incredible-gen` or with Nix, `nix run .#incredible:exe:incredible-gen`.
### VM
You can build a Nix VM with the frontend and backend built with the configuration in `/config` with `nixos-rebuild build-vm --flake .#incredible-vm`.
The VM can then be run with `./result/bin/run-nixos-vm`.
================================================
FILE: api-spec.json
================================================
{"info":{"description":"Swagger API docs for Incredible","title":"Incredible API","version":"0"},"paths":{"/blueprint/file":{"post":{"parameters":[{"in":"header","name":"X-WorkOrder","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Blueprint"}}}},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":[{"$ref":"#/components/schemas/UUID"},{"items":[{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}],"maxItems":2,"minItems":2,"type":"array"}],"maxItems":2,"minItems":2,"type":"array"}}},"description":""},"400":{"description":"Invalid `body` or `X-WorkOrder`"}}}},"/folio/{blueprintid}":{"get":{"parameters":[{"in":"path","name":"blueprintid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Folio"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"404":{"description":"`blueprintid` not found"}}}},"/machine/current":{"get":{"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VersionedMachine (Maybe BlueprintID)"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}}}}}}},"/machine/current/version":{"get":{"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"type":"integer"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}}}}}}},"/machine/{version}":{"get":{"parameters":[{"in":"path","name":"version","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VersionedMachine (Maybe BlueprintID)"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"404":{"description":"`version` not found"}}}},"/machine/delta/{startVersion}/current":{"get":{"parameters":[{"in":"path","name":"startVersion","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":[{"type":"integer"},{"$ref":"#/components/schemas/VersionedMachine ModData"}],"maxItems":2,"minItems":2,"type":"array"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"404":{"description":"`startVersion` not found"}}}},"/machine/delta/{startVersion}/{endVersion}":{"get":{"parameters":[{"in":"path","name":"startVersion","required":true,"schema":{"type":"integer"}},{"in":"path","name":"endVersion","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":[{"type":"integer"},{"$ref":"#/components/schemas/VersionedMachine ModData"}],"maxItems":2,"minItems":2,"type":"array"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"404":{"description":"`startVersion` or `endVersion` not found"}}}},"/puzzle":{"get":{"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/Puzzle"},"type":"object"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}},"X-WorkOrder":{"schema":{"type":"string"}}}}}}},"/moderate/puzzle/{puzzleid}/blueprintid":{"get":{"parameters":[{"in":"path","name":"puzzleid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"404":{"description":"`puzzleid` not found"}},"security":[{"BasicAuth":[]}]}},"/moderate/puzzle/{puzzleid}/blueprint":{"get":{"parameters":[{"in":"path","name":"puzzleid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"items":[{"$ref":"#/components/schemas/UUID"},{"$ref":"#/components/schemas/Blueprint"}],"maxItems":2,"minItems":2,"type":"array"},"type":"array"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"404":{"description":"`puzzleid` not found"}},"security":[{"BasicAuth":[]}]}},"/moderate/puzzle/{puzzleid}":{"get":{"parameters":[{"in":"path","name":"puzzleid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Puzzle"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}}}},"404":{"description":"`puzzleid` not found"}},"security":[{"BasicAuth":[]}]}},"/moderate/puzzle/{puzzleid}/reissue":{"post":{"parameters":[{"in":"path","name":"puzzleid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":""},"404":{"description":"`puzzleid` not found"}},"security":[{"BasicAuth":[]}]}},"/moderate/build/{X}/{Y}":{"post":{"parameters":[{"in":"path","name":"X","required":true,"schema":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},{"in":"path","name":"Y","required":true,"schema":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InspectionReport"}}}},"responses":{"204":{"description":""},"400":{"description":"Invalid `body`"},"404":{"description":"`X` or `Y` not found"}},"security":[{"BasicAuth":[]}]}},"/moderate/burn/{blueprintid}":{"post":{"parameters":[{"in":"path","name":"blueprintid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":""},"404":{"description":"`blueprintid` not found"}},"security":[{"BasicAuth":[]}]}},"/moderate/machine/current":{"get":{"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VersionedMachine ModData"}}},"description":"","headers":{"Cache-Control":{"schema":{"type":"string"}}}}},"security":[{"BasicAuth":[]}]}}},"components":{"schemas":{"UUID":{"example":"00000000-0000-0000-0000-000000000000","format":"uuid","type":"string"},"Blueprint":{"example":{"puzzle":"00000000-0000-0000-0000-000000000000","submittedAt":null,"title":"Lauren Ipsum","widgets":{}},"properties":{"puzzle":{"type":"string"},"submittedAt":{"$ref":"#/components/schemas/UTCTime"},"title":{"type":"string"},"widgets":{"$ref":"#/components/schemas/Object"}},"required":["puzzle","title","widgets"],"type":"object"},"UTCTime":{"example":"2016-07-22T00:00:00Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"Object":{"additionalProperties":true,"description":"Arbitrary JSON object.","type":"object"},"Folio":{"properties":{"blueprint":{"$ref":"#/components/schemas/Blueprint"},"puzzle":{"$ref":"#/components/schemas/Puzzle"},"snapshot":{"$ref":"#/components/schemas/Object"}},"required":["puzzle","blueprint","snapshot"],"type":"object"},"Puzzle":{"example":{"inputs":[{"balls":[{"rate":1,"type":1}],"x":0.5,"y":0}],"outputs":[{"balls":[{"rate":1,"type":1}],"x":0.5,"y":1}],"reqTiles":["UpLeft"],"spec":{}},"properties":{"inputs":{"items":{"$ref":"#/components/schemas/Gateway"},"type":"array"},"outputs":{"items":{"$ref":"#/components/schemas/Gateway"},"type":"array"},"reqTiles":{"items":{"$ref":"#/components/schemas/RelativeCell"},"type":"array"},"spec":{"$ref":"#/components/schemas/Object"}},"required":["reqTiles","inputs","outputs","spec"],"type":"object"},"RelativeCell":{"enum":["UpLeft","Up","UpRight","Left","Right","DownLeft","Down","DownRight"],"type":"string"},"Gateway":{"example":{"blueprint":"00000000-0000-0000-0000-000000000000","puzzle":"00000000-0000-0000-0000-000000000000","to_mod":20},"properties":{"balls":{"items":{"$ref":"#/components/schemas/GatewayBall"},"type":"array"},"x":{"format":"double","type":"number"},"y":{"format":"double","type":"number"}},"required":["x","y","balls"],"type":"object"},"GatewayBall":{"properties":{"rate":{"format":"double","type":"number"},"type":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["type","rate"],"type":"object"},"VersionedMachine (Maybe BlueprintID)":{"example":{"grid":[["00000000-0000-0000-0000-000000000000","00000000-0000-0000-0000-000000000000"],["00000000-0000-0000-0000-000000000000",null]],"ms_per_ball":1.0e-3,"prio_puzzles":[],"tile_size":{"x":700,"y":700},"version":0},"properties":{"grid":{"items":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"type":"array"},"ms_per_ball":{"format":"double","type":"number"},"prio_puzzle":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"tile_size":{"$ref":"#/components/schemas/TileSize"},"version":{"type":"integer"}},"required":["grid","version","tile_size","ms_per_ball"],"type":"object"},"TileSize":{"properties":{"x":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"y":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["x","y"],"type":"object"},"VersionedMachine ModData":{"example":{"grid":[[{"blueprint":"00000000-0000-0000-0000-000000000000","puzzle":"00000000-0000-0000-0000-000000000000"},{"puzzle":"00000000-0000-0000-0000-000000000000","to_mod":20}],[{"puzzle":"00000000-0000-0000-0000-000000000000","to_mod":11},{"puzzle":"00000000-0000-0000-0000-000000000000"}]],"ms_per_ball":1.0e-3,"prio_puzzles":[],"tile_size":{"x":700,"y":700},"version":0},"properties":{"grid":{"items":{"items":{"$ref":"#/components/schemas/ModData"},"type":"array"},"type":"array"},"ms_per_ball":{"format":"double","type":"number"},"prio_puzzle":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"tile_size":{"$ref":"#/components/schemas/TileSize"},"version":{"type":"integer"}},"required":["grid","version","tile_size","ms_per_ball"],"type":"object"},"InspectionReport":{"properties":{"blueprint":{"$ref":"#/components/schemas/UUID"},"snapshot":{"$ref":"#/components/schemas/Object"}},"required":["blueprint","snapshot"],"type":"object"},"ModData":{"example":{"blueprint":"00000000-0000-0000-0000-000000000000","puzzle":"00000000-0000-0000-0000-000000000000","to_mod":20},"properties":{"blueprint":{"type":"string"},"puzzle":{"type":"string"},"to_mod":{"type":"integer"}},"required":["puzzle"],"type":"object"}},"securitySchemes":{"BasicAuth":{"description":"Basic Authentication","scheme":"basic","type":"http"}}},"openapi":"3.0.0"}
================================================
FILE: app/Main.hs
================================================
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Incredible.Config
import qualified Incredible.DataStore.Memory as MemoryStore
import qualified Incredible.DataStore.Redis as IRedis
import Incredible.App
import Network.Wai.Handler.Warp
import Options.Applicative
data IncredibleOptions = IncredibleOptions {
incredibleConfigPath :: FilePath
, incredibleMachinePath :: FilePath
, incredibleDBMem :: Bool
}
incredibleOpts :: Parser IncredibleOptions
incredibleOpts = do
IncredibleOptions <$> configPath <*> machinePath <*> useMem
where
configPath = strOption $ mconcat [
long "config"
, short 'c'
, metavar "CONFIG"
, value "config/incredible.toml"
, help "Path to the incredible config file"
]
machinePath = strOption $ mconcat [
long "machine"
, short 'm'
, metavar "MACHINE"
, value "config/machine.json"
, help "Path to the incredible machine file"
]
useMem = flag False True $ mconcat [
long "mem"
, help "if we should use an in-memory datastore."
]
main :: IO ()
main = do
putStrLn "Starting Incredible..."
opts <- execParser $ info (incredibleOpts <**> helper) fullDesc
putStrLn $ "Using config file: " ++ incredibleConfigPath opts
cnf <- readIncredibleConfig $ incredibleConfigPath opts
putStrLn $ "Using machine file: " ++ incredibleMachinePath opts
puzzleMachine <- loadPuzzleMachine $ incredibleMachinePath opts
putStrLn $ "Init incredible app..."
webApp <- if incredibleDBMem opts
then incredibleApp <$> openMachineShop cnf puzzleMachine MemoryStore.initIncredibleState
else incredibleApp <$> openMachineShop cnf puzzleMachine IRedis.initIncredibleState
let runWeb = run (incredibleWebPort $ incredibleWebConfig cnf) webApp
putStrLn $ "Running on port: " ++ show (incredibleWebPort $ incredibleWebConfig cnf)
runWeb
================================================
FILE: client/babel.config.json
================================================
{
"presets": [
["@babel/preset-env", { "targets": "defaults" }],
"@babel/preset-typescript",
[
"@babel/preset-react",
{
"runtime": "automatic",
"importSource": "@emotion/react"
}
]
],
"plugins": ["@emotion"]
}
================================================
FILE: client/comic.json
================================================
{
"name": "Incredible",
"alt": "The Credible Machine",
"url": "/incredible",
"publicPath": "/",
"width": 740,
"height": 740,
"apiEndpoint": "http://localhost:8888/"
}
================================================
FILE: client/eslint.config.js
================================================
import eslint from '@eslint/js'
import * as reactQuery from '@tanstack/eslint-plugin-query'
import hooksPlugin from 'eslint-plugin-react-hooks'
import jsxRuntime from 'eslint-plugin-react/configs/jsx-runtime.js'
import reactRecommended from 'eslint-plugin-react/configs/recommended.js'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['*.config.js', 'loaders/*'] },
{ files: ['src/*'] },
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
reactRecommended,
jsxRuntime,
{
plugins: { '@tanstack/query': reactQuery },
rules: reactQuery.configs.recommended.rules,
},
{
plugins: { 'react-hooks': hooksPlugin },
rules: hooksPlugin.configs.recommended.rules,
},
{
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'react/no-unknown-property': ['error', { ignore: ['css'] }],
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks:
'(useRapierEffect|useRigidBody|useCollider|useLoopHandler)',
},
],
},
},
{
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
)
================================================
FILE: client/loaders/comic-image-loader.js
================================================
import loaderUtils from 'loader-utils'
import sharp from 'sharp'
import { callbackify } from 'util'
async function processImage(inputBuffer) {
const options = this.getOptions()
const img = sharp(inputBuffer)
const meta = await img.metadata()
const scaleImage = async (scale) => {
const { data, info } = await img
.resize({
width: Math.floor(meta.width * (scale / options.baseScale)),
})
.png({ palette: !!options.quant })
.toBuffer({ resolveWithObject: true })
const interpolatedName = loaderUtils.interpolateName(this, options.name, {
content: data,
})
this.emitFile(interpolatedName, data)
return { scale, data, info, interpolatedName }
}
const publicPath = options.publicPath ? options.publicPath : ''
const scaleResults = await Promise.all(options.scales.map(scaleImage))
const url = {}
const srcSetItems = []
for (const result of scaleResults) {
const imgURL = publicPath + result.interpolatedName
url[`${result.scale}x`] = imgURL
srcSetItems.push(`${imgURL} ${result.scale}x`)
}
const outputData = {
width: Math.floor(meta.width * (1 / options.baseScale)),
height: Math.floor(meta.height * (1 / options.baseScale)),
url,
srcSet: srcSetItems.join(', '),
}
return `module.exports = ${JSON.stringify(outputData)}`
}
const processImageCb = callbackify(processImage)
export default function downscale(inputBuffer) {
this.cacheable()
const cb = this.async()
processImageCb.call(this, inputBuffer, cb)
}
export const raw = true
================================================
FILE: client/package.json
================================================
{
"name": "incredible",
"version": "1.0.0",
"description": "TBD",
"private": true,
"main": "src/index.tsx",
"type": "module",
"scripts": {
"build": "webpack --mode production",
"profile": "webpack --mode production --profile --json=bundle-stats.json",
"build:dev": "webpack --mode development",
"build:api-types": "openapi-typescript ../api-spec.json -o ./src/generated/api-spec.d.ts",
"start:dev": "webpack serve --mode=development"
},
"author": "Max Goodhart <c@chromakode.com>",
"license": "MIT",
"dependencies": {
"@babel/preset-react": "^7.24.1",
"@dimforge/rapier2d": "^0.12.0",
"@emotion/react": "^11.11.4",
"@react-hook/intersection-observer": "^3.1.1",
"@react-hook/latest": "^1.0.3",
"@react-hook/size": "^2.1.2",
"@tanstack/react-query": "^5.28.9",
"@types/lodash": "^4.17.0",
"assert-never": "^1.2.1",
"framer-motion": "^11.0.22",
"html-webpack-plugin": "^5.6.0",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"openapi-fetch": "^0.9.3",
"prettier": "^3.2.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-moveable": "^0.56.0",
"terser-webpack-plugin": "^5.3.10",
"tiny-invariant": "^1.3.3",
"typescript": "^5.4.2",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4",
"weighted": "^1.0.0"
},
"devDependencies": {
"@babel/core": "^7.24.3",
"@babel/preset-env": "^7.24.3",
"@babel/preset-typescript": "^7.24.1",
"@emotion/babel-plugin": "^11.11.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@tanstack/eslint-plugin-query": "^5.28.6",
"@types/prettier": "^2.7.3",
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
"babel-loader": "^9.1.3",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"loader-utils": "^3.2.1",
"openapi-typescript": "^6.7.5",
"prettier-plugin-organize-imports": "^3.2.4",
"sharp": "^0.33.3",
"typescript-eslint": "^7.3.1",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-dev-server": "^4.15.2"
},
"engines": {
"node": ">=20.0.0 <21.0.0"
}
}
================================================
FILE: client/prettier.config.mjs
================================================
export default {
trailingComma: 'all',
tabWidth: 2,
semi: false,
singleQuote: true,
plugins: ['prettier-plugin-organize-imports'],
}
================================================
FILE: client/src/api.ts
================================================
import { QueryClient } from '@tanstack/react-query'
import createClient, { Middleware } from 'openapi-fetch'
import comic from '../comic.json'
import type { paths } from './generated/api-spec'
export const apiClient = createClient<paths>({
baseUrl: process.env.API_ENDPOINT ?? comic.apiEndpoint,
})
const throwOnError: Middleware = {
async onResponse(res) {
if (res.status >= 400) {
const body: string = await res.clone().text()
throw new Error(body)
}
return undefined
},
}
apiClient.use(throwOnError)
export const queryClient = new QueryClient()
================================================
FILE: client/src/components/BallPitMechanism.tsx
================================================
import { useCallback, useMemo, useRef } from 'react'
import { coords, vectorAngle } from '../lib/coords'
import { inBounds } from '../lib/utils'
import { Ball, BallType, Bounds } from '../types'
import {
MachineContextProvider,
MachineContextProviderRef,
useMachine,
} from './MachineContext'
import { GRAVITY, PhysicsContextProvider } from './PhysicsContext'
import {
BOTTOM_CHUTE_DROP,
BOTTOM_CHUTE_EXIT_OFFSET,
BOTTOM_MECHANISM_HEIGHT,
BOTTOM_PIT_HEIGHT,
BOTTOM_TANK_HEIGHT,
BOTTOM_TANK_SPACING,
BOTTOM_TANK_TARGET_TOP,
BOTTOM_TANK_WIDTH,
} from './constants'
import { useMetaMachineClient } from './useMetaMachineClient'
import { BASE_BALL_LIFETIME_TICKS, Balls } from './widgets/Balls'
import Boat from './widgets/Boat'
import { BottomChute } from './widgets/BottomChute'
import { BottomPit } from './widgets/BottomPit'
import { BottomTank } from './widgets/BottomTank'
import { BallSpawner } from './widgets/SpawnInput'
const tankIdxByType = [2, 0, 3, 1]
/**
* Bottom pool with balls fed by chutes at the bottom of the puzzle.
*
*/
export function BallPitMechanism({
tilesX,
tilesY,
tileWidth,
tileHeight,
stepRateMultiplier = 1,
}: {
tilesX: number
tilesY: number
tileWidth: number
tileHeight: number
stepRateMultiplier?: number
}) {
const { msPerBall, simulationBoundsRef } = useMachine()
const subMachineRef = useRef<MachineContextProviderRef>(null)
const totalWidth = tilesX * tileWidth
const totalHeight = tilesY * tileHeight
const pitX = totalWidth / 2
const pitY =
totalHeight + BOTTOM_MECHANISM_HEIGHT - BOTTOM_PIT_HEIGHT / 2 - 10
const tankCount = 4
const firstTankCenterX =
pitX -
(4 * BOTTOM_TANK_WIDTH + 3 * BOTTOM_TANK_SPACING) / 2 +
BOTTOM_TANK_WIDTH / 2
const tankTopY = pitY - BOTTOM_TANK_HEIGHT - 400
const lastLineBounds: Bounds = [
0,
(tilesY - 1) * tileHeight,
totalWidth,
tilesY * tileHeight,
]
const bounds: Bounds = useMemo(
() => [0, 0, totalWidth, totalHeight + BOTTOM_MECHANISM_HEIGHT],
[totalHeight, totalWidth],
)
// Load the last row of machines
const { metaMachine } = useMetaMachineClient({ viewBounds: lastLineBounds })
/**
* Velocity of ball to hit the bool target
*/
const getBallVx = useCallback(
(x: number, y: number, ballType: BallType) => {
const tankIdx = tankIdxByType[ballType - 1]
const targetX =
firstTankCenterX + tankIdx * (BOTTOM_TANK_WIDTH + BOTTOM_TANK_SPACING)
const [tx, ty] = coords.toRapier.vector(
targetX,
tankTopY + BOTTOM_TANK_TARGET_TOP,
)
return (tx - x) / Math.sqrt((2 * (y - ty)) / -GRAVITY.y)
},
[firstTankCenterX, tankTopY],
)
// When balls are received in the chutes in the meta machine, spawn a ball in our machine targeting the pool.
const handleReceiveBall = useCallback(
(ball: Ball) => {
const { current: subMachine } = subMachineRef
if (!subMachine) {
return
}
const { x, y } = ball.translation()
subMachine.createBall(
...coords.fromRapier.vector(x, y),
ball.userData.ballType,
{
vx: getBallVx(x, y, ball.userData.ballType),
overrideDamping: 0,
},
)
},
[getBallVx],
)
if (!metaMachine) {
return null
}
const tanks = []
for (let tankType = 1; tankType <= tankCount; tankType++) {
tanks.push(
<BottomTank
x={
firstTankCenterX +
tankIdxByType[tankType - 1] *
(BOTTOM_TANK_WIDTH + BOTTOM_TANK_SPACING)
}
y={tankTopY + BOTTOM_TANK_HEIGHT / 2}
type={tankType}
key={`tank-${tankType}`}
/>,
)
}
const chutes = []
const ballSpawns = []
for (let xt = 0; xt < tilesX; xt++) {
const machine = metaMachine.getMachine(xt, tilesY - 1)
if (!machine) {
continue
}
for (const output of machine.puzzle.outputs) {
if (output.y !== 1) {
continue
}
const x = xt * tileWidth + tileWidth * output.x
const y = totalHeight
// If the output exists in simulation, put a chute under it, otherwise, proxy it with a spawner.
if (inBounds(x, y, simulationBoundsRef.current)) {
chutes.push(
<BottomChute
key={`${xt}-${output.x}`}
x={x}
y={y + BOTTOM_CHUTE_DROP}
angle={vectorAngle(
{ x, y: y + BOTTOM_CHUTE_EXIT_OFFSET },
{ x: pitX, y: pitY },
)}
onReceiveBall={handleReceiveBall}
/>,
)
} else {
ballSpawns.push(
<BallSpawner
key={`${xt}-${output.x}`}
x={x}
y={y + BOTTOM_CHUTE_DROP}
vx={getBallVx(
...coords.toRapier.vector(x, y + BOTTOM_CHUTE_DROP),
output.balls[0]['type'], // No combined outputs at bottom of map
)}
overrideDamping={0}
balls={output.balls}
/>,
)
}
}
}
return (
<>
{chutes}
<PhysicsContextProvider stepRateMultiplier={stepRateMultiplier}>
<MachineContextProvider
ref={subMachineRef}
msPerBall={msPerBall}
initialSimulationBounds={bounds}
initialViewBounds={bounds}
>
{ballSpawns}
{tanks}
<Boat id="boat" x={pitX} y={pitY - 10} />
<BottomPit x={pitX} y={pitY} />
<Balls lifetimeTicks={3.5 * BASE_BALL_LIFETIME_TICKS} />
</MachineContextProvider>
</PhysicsContextProvider>
</>
)
}
================================================
FILE: client/src/components/CenteredSlippyMap.tsx
================================================
import { animate, motion, useMotionValue, useTransform } from 'framer-motion'
import {
ComponentProps,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import { Bounds } from '../types'
export interface SlippyMapRef {
getPosition(): [x: number, y: number]
isAnimating(): boolean
animateTo(x: number, y: number, zoom?: number): Promise<void>
jumpTo(x: number, y: number, zoom?: number): void
stop(): void
}
export interface CenteredSlippyMapProps {
width: number
height: number
totalWidth: number
totalHeight: number
innerClassName?: string
onPosition: (viewBounds: Bounds) => void
onDragStart?: ComponentProps<typeof motion.div>['onDragStart']
children: React.ReactElement
initialX?: number
initialY?: number
initialZoom?: number
}
export const CenteredSlippyMap = forwardRef<
SlippyMapRef,
CenteredSlippyMapProps
>(function CenteredSlippyMap(
{
width,
height,
totalWidth,
totalHeight,
innerClassName,
onPosition,
onDragStart,
children,
initialX = 0,
initialY = 0,
initialZoom = 1,
}: CenteredSlippyMapProps,
ref,
) {
const scaleVal = useMotionValue(initialZoom)
const [scale, setScale] = useState(initialZoom)
// The input position in unzoomed child screen space, centered in viewport.
// We need to scale it by the zoom level into screen space.
const centerXVal = useMotionValue(-initialX * initialZoom)
const centerYVal = useMotionValue(-initialY * initialZoom)
// Translation offset in screen space of drag gesture
const dragXVal = useTransform(() => centerXVal.get() + 0.5 * width)
const dragYVal = useTransform(() => centerYVal.get() + 0.5 * height)
const handleUpdate = useCallback(() => {
const x = dragXVal.get()
const y = dragYVal.get()
const scale = scaleVal.get()
const scaleFactor = 1 / scale
const viewBounds: Bounds = [
-x * scaleFactor,
-y * scaleFactor,
(-x + width) * scaleFactor,
(-y + height) * scaleFactor,
]
onPosition(viewBounds)
}, [dragXVal, dragYVal, scaleVal, width, height, onPosition])
// Initial set of bounds on mount
useEffect(handleUpdate, [handleUpdate])
const getPosition = useCallback(() => {
return [
-centerXVal.get() / scaleVal.get(),
-centerYVal.get() / scaleVal.get(),
] as [number, number]
}, [centerXVal, centerYVal, scaleVal])
const isAnimating = useCallback(() => {
return centerXVal.isAnimating() || centerYVal.isAnimating()
}, [centerXVal, centerYVal])
const animateTo = useCallback(
async (x: number, y: number, zoom: number = 1) => {
await Promise.all([
setScale(zoom),
animate(scaleVal, zoom),
animate(centerXVal, -x * zoom),
animate(centerYVal, -y * zoom),
])
},
[scaleVal, centerXVal, centerYVal],
)
const jumpTo = useCallback(
(x: number, y: number, zoom: number = 1) => {
setScale(zoom)
scaleVal.set(zoom)
centerXVal.set(-x * zoom)
centerYVal.set(-y * zoom)
},
[scaleVal, centerXVal, centerYVal],
)
const stop = useCallback(() => {
scaleVal.stop()
centerXVal.stop()
centerYVal.stop()
}, [scaleVal, centerXVal, centerYVal])
useImperativeHandle(ref, () => ({
getPosition,
isAnimating,
animateTo,
jumpTo,
stop,
}))
const dragConstraints = useMemo(() => {
const right = -0.5 * scale * width
const left = right - (totalWidth - width) * scale
const bottom = -0.5 * scale * height
const top = bottom - (totalHeight - height) * scale
return {
left,
right,
top,
bottom,
}
}, [height, scale, totalHeight, totalWidth, width])
return (
<div
css={{
overflow: 'hidden',
width,
height,
userSelect: 'none',
// Allow framer to handle touch pans
touchAction: 'none',
}}
>
<motion.div
css={{
position: 'relative',
width: totalWidth,
height: totalHeight,
overflow: 'hidden',
contain: 'strict',
}}
style={{
x: dragXVal,
y: dragYVal,
scale: scaleVal,
originX: 0,
originY: 0,
}}
className={innerClassName}
whileDrag={{ cursor: 'grabbing' }}
onDragStart={onDragStart}
onUpdate={handleUpdate}
dragConstraints={dragConstraints}
_dragX={centerXVal}
_dragY={centerYVal}
drag
>
{children}
</motion.div>
</div>
)
})
================================================
FILE: client/src/components/Comic.tsx
================================================
import imgEdit from '@art/edit-icon_4x.png'
import imgView from '@art/view-icon_4x.png'
import { motion } from 'framer-motion'
import { isEqual, random, throttle } from 'lodash'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import comic from '../../comic.json'
import { Bounds } from '../types'
import { ComicBrowseView } from './ComicBrowseView'
import { ComicImage } from './ComicImage'
import { ComicPuzzleView } from './ComicPuzzleView'
import { FullscreenComicContainer } from './FullscreenComicContainer'
import LoadingSpinner from './LoadingSpinner'
import { ToolButton, comicDropShadow } from './WidgetPalette'
import { paramToNumber, useLocationHashParams } from './useLocationHashParams'
import {
SavedMachine,
useGetPuzzles,
useMetaMachineClient,
} from './useMetaMachineClient'
export interface MetaMachineLink {
xt: number
yt: number
v?: number
}
export function useHashLink(): {
hashLink: MetaMachineLink | null
updateHashLink: (link: MetaMachineLink) => void
} {
const { locationHashParams, setLocationHashParams } = useLocationHashParams()
const lastRef = useRef<MetaMachineLink | null>(null)
const hashLink = useMemo(() => {
const xt = paramToNumber(locationHashParams.get('xt'))
const yt = paramToNumber(locationHashParams.get('yt'))
const v = paramToNumber(locationHashParams.get('v'))
if (xt == null || yt == null) {
return null
}
const nextVal = {
xt,
yt,
v,
}
lastRef.current = nextVal
return nextVal
}, [locationHashParams])
const updateHashLink = useCallback(
(update: MetaMachineLink) => {
const nextVal = { ...lastRef.current, ...update }
if (isEqual(nextVal, lastRef.current)) {
return
}
lastRef.current = nextVal
const { xt, yt, v } = nextVal
setLocationHashParams({
xt: String(xt),
yt: String(yt),
v: v != null ? String(v) : undefined,
})
},
[setLocationHashParams],
)
return { hashLink, updateHashLink }
}
export default function Comic() {
const { hashLink, updateHashLink } = useHashLink()
const [mode, setMode] = useState<'create' | 'browse'>(
hashLink ? 'browse' : 'create',
)
const [viewBounds, setViewBounds] = useState<Bounds>(() => [
0,
0,
comic.width,
comic.height,
])
const throttledSetViewBounds = useMemo(() => throttle(setViewBounds, 250), [])
const [lastSubmission, setLastSubmission] = useState<SavedMachine>()
const { metaMachine, allTilesLoaded } = useMetaMachineClient({
viewBounds,
lastSubmission,
version: hashLink?.v,
})
const [isInitialLoading, setInitialLoading] = useState(true)
useEffect(() => {
if (allTilesLoaded) {
setInitialLoading(false)
}
}, [allTilesLoaded])
const puzzles = useGetPuzzles()
// TODO: track seen puzzles
const [puzzleIdx, setPuzzleIdx] = useState<number>(0)
useEffect(() => {
if (!puzzles) {
return
}
setPuzzleIdx(random(puzzles.length - 1))
}, [puzzles])
const handleSubmitBlueprint = useCallback((location: SavedMachine) => {
setLastSubmission(location)
setMode('browse')
}, [])
const handleToggleMode = useCallback(() => {
setMode((curMode) => (curMode === 'create' ? 'browse' : 'create'))
}, [])
if (!metaMachine) {
return <LoadingSpinner />
}
const puzzle = puzzles ? puzzles[puzzleIdx] : undefined
return (
<FullscreenComicContainer>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: isInitialLoading ? 0 : 1 }}
transition={{ delay: 0.25 }}
css={{ contain: 'paint' }}
>
<ComicBrowseView
metaMachine={metaMachine}
lastSubmission={lastSubmission}
onPosition={throttledSetViewBounds}
hashLink={hashLink}
updateHashLink={updateHashLink}
isActive={mode === 'browse'}
/>
</motion.div>
{puzzle && (
<motion.div
css={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
backgroundColor: 'white',
}}
initial={{ opacity: mode === 'create' ? 1 : 0 }}
animate={{
opacity: mode === 'create' ? 1 : 0,
scale: mode === 'create' ? 1 : 0.9,
}}
transition={{ type: 'spring', duration: 0.25 }}
style={{
pointerEvents: mode === 'create' && puzzle ? 'all' : 'none',
}}
>
<ComicPuzzleView
key={`${puzzle.id}-${lastSubmission?.blueprintId}`}
puzzle={puzzle}
metaMachine={metaMachine}
onSubmit={handleSubmitBlueprint}
isActive={mode === 'create'}
/>
</motion.div>
)}
{puzzle && (
<ToolButton
initial={{ scale: 0 }}
animate={
mode === 'create' ? { scale: 1 } : { scale: 1, rotateY: 180 }
}
transition={isInitialLoading ? { scale: { delay: 0.5 } } : undefined}
onClick={handleToggleMode}
aria-label={
mode === 'browse' ? 'Build another blueprint' : 'View the machine'
}
css={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
right: 10,
bottom: 10,
width: 50,
height: 50,
transformStyle: 'preserve-3d',
}}
>
<ComicImage
img={imgEdit}
css={[
{
position: 'absolute',
transform: 'rotateY(180deg) translateZ(0.01px)',
backfaceVisibility: 'hidden',
},
comicDropShadow,
]}
/>
<ComicImage
img={imgView}
css={[
{
backfaceVisibility: 'hidden',
},
comicDropShadow,
]}
/>
</ToolButton>
)}
</FullscreenComicContainer>
)
}
================================================
FILE: client/src/components/ComicBrowseView.tsx
================================================
import imgFollowBall from '@art/follow-ball_4x.png'
import imgPermalink from '@art/permalink_4x.png'
import { AnimatePresence, motion } from 'framer-motion'
import { random } from 'lodash'
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { Bounds } from '../types'
import { MetaMachineLink } from './Comic'
import { ComicImage } from './ComicImage'
import LoadingSpinner from './LoadingSpinner'
import {
SlippyMetaMachineRef,
SlippyMetaMachineView,
SlippyMetaMachineViewProps,
} from './MetaMachineView'
import { PhysicsContextProvider, PhysicsLoader } from './PhysicsContext'
import { ToolButton, comicDropShadow } from './WidgetPalette'
import { MetaMachineInfo, SavedMachine } from './useMetaMachineClient'
function findRandomTile(metaMachine: MetaMachineInfo) {
const { tilesX, tilesY } = metaMachine
for (let tries = 0; tries < 16; tries++) {
const xt = random(0, tilesX - 1)
const yt = random(0, tilesY - 1)
if (metaMachine.hasBlueprint(xt, yt)) {
return { xt, yt }
}
}
// <3, jamslunt interfoggle
return { xt: 2, yt: 2 }
}
export function ComicBrowseView({
lastSubmission,
metaMachine,
isActive,
onPosition,
hashLink,
updateHashLink,
}: {
lastSubmission: SavedMachine | undefined
metaMachine: MetaMachineInfo
isActive: boolean
onPosition: SlippyMetaMachineViewProps['onPosition']
hashLink: MetaMachineLink | null
updateHashLink: (link: MetaMachineLink) => void
}) {
const viewRef = useRef<SlippyMetaMachineRef>(null)
const getMap = () => viewRef?.current?.mapRef?.current
const getMachine = () => viewRef?.current?.machineRef?.current
const { tileWidth, tileHeight, tilesX, tilesY } = metaMachine
function centeredScreenCoords({ xt, yt }: { xt: number; yt: number }) {
return [xt * tileWidth + tileWidth / 2, yt * tileHeight + tileHeight / 2]
}
const [initialCenterX, initialCenterY] = centeredScreenCoords(
lastSubmission ?? hashLink ?? findRandomTile(metaMachine),
)
const isMovingToSubmission = useRef(false)
useLayoutEffect(() => {
async function doSubmitAnimate() {
if (lastSubmission) {
isMovingToSubmission.current = true
getMachine()?.unfollowBall()
getMap()?.jumpTo(initialCenterX, initialCenterY, 1)
await new Promise((r) => setTimeout(r, 150))
await getMap()?.animateTo(initialCenterX, initialCenterY, 0.8)
updateHashLink({
xt: lastSubmission.xt,
yt: lastSubmission.yt,
})
isMovingToSubmission.current = false
}
}
void doSubmitAnimate()
}, [lastSubmission, initialCenterX, initialCenterY, updateHashLink])
const updateHashLinkFromPosition = useCallback(
({ includeVersion }: { includeVersion?: boolean } = {}) => {
const map = getMap()
if (!map) {
return
}
const [x, y] = map.getPosition()
const xt = Math.floor(x / tileWidth)
const yt = Math.floor(y / tileHeight)
if (xt < 0 || xt > tilesX - 1 || yt < 0 || yt > tilesY - 1) {
return
}
const nextHashLink: MetaMachineLink = { xt, yt }
if (includeVersion) {
nextHashLink.v = metaMachine.version
}
updateHashLink(nextHashLink)
},
[
metaMachine.version,
tileHeight,
tileWidth,
tilesX,
tilesY,
updateHashLink,
],
)
const handlePosition = useCallback(
(viewBounds: Bounds) => {
onPosition?.(viewBounds)
if (
getMap()?.isAnimating() ||
isMovingToSubmission.current ||
!isActive
) {
return
}
updateHashLinkFromPosition()
},
[onPosition, isActive, updateHashLinkFromPosition],
)
useEffect(() => {
const map = getMap()
if (!hashLink || !map) {
return
}
const [x, y] = map.getPosition()
const xt = Math.floor(x / tileWidth)
const yt = Math.floor(y / tileHeight)
if (hashLink.xt === xt && hashLink.yt === yt) {
return
}
getMachine()?.unfollowBall()
void map.animateTo(
hashLink.xt * tileWidth + tileWidth / 2,
hashLink.yt * tileHeight + tileHeight / 2,
0.8,
)
}, [hashLink, tileHeight, tileWidth])
const [hasCopied, setHasCopied] = useState(false)
const handleClickPermalink = useCallback(() => {
if (!hashLink) {
return
}
async function doCopy() {
updateHashLinkFromPosition({ includeVersion: true })
await navigator.clipboard?.writeText(window.location.toString())
setHasCopied(true)
await new Promise((r) => setTimeout(r, 1000))
setHasCopied(false)
}
void doCopy()
}, [hashLink, updateHashLinkFromPosition])
// Ball follow mode
const startFollowingBall = useCallback(() => {
const map = getMap()
const machine = getMachine()
if (!map || !machine) {
return
}
map.stop()
let [lastX, lastY] = map.getPosition()
machine.followBall((x: number, y: number) => {
const nextX = lastX * 0.75 + x * 0.25
const nextY = lastY * 0.75 + y * 0.25
map.jumpTo(nextX, nextY, 0.8)
lastX = nextX
lastY = nextY
})
}, [])
const handleDragStart = useCallback(() => {
getMachine()?.unfollowBall()
}, [])
useEffect(() => {
function handleKey(ev: KeyboardEvent) {
if (ev.key.toLowerCase() === 'b' && ev.ctrlKey && ev.altKey) {
startFollowingBall()
}
}
window.addEventListener('keydown', handleKey, false)
return () => {
window.removeEventListener('keydown', handleKey, false)
}
}, [startFollowingBall])
return (
<>
<PhysicsContextProvider stepRateMultiplier={isActive ? 1 : 0}>
<PhysicsLoader spinner={<LoadingSpinner />}>
<SlippyMetaMachineView
ref={viewRef}
{...metaMachine}
initialX={initialCenterX}
initialY={initialCenterY}
initialZoom={0.8}
onPosition={handlePosition}
onDragStart={handleDragStart}
/>
</PhysicsLoader>
</PhysicsContextProvider>
<ToolButton
onClick={startFollowingBall}
aria-label="Follow a random ball"
css={[
{
position: 'absolute',
left: 10,
bottom: 10,
width: 50,
height: 50,
cursor: 'pointer',
},
comicDropShadow,
]}
>
<ComicImage img={imgFollowBall} />
</ToolButton>
<ToolButton
onClick={handleClickPermalink}
aria-label="Copy permalink"
css={[
{
position: 'absolute',
left: 10,
top: 10,
width: 50,
height: 50,
cursor: 'pointer',
},
comicDropShadow,
]}
>
<ComicImage img={imgPermalink} />
</ToolButton>
<AnimatePresence>
{hasCopied && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { delay: 1 } }}
css={[
{
position: 'absolute',
left: 68,
top: 10,
fontFamily: 'xkcd-Regular-v3',
background: 'black',
color: 'white',
padding: '4px 8px',
},
comicDropShadow,
]}
>
Copied!
</motion.div>
)}
</AnimatePresence>
</>
)
}
================================================
FILE: client/src/components/ComicImage.tsx
================================================
import {
HTMLAttributes,
ImgHTMLAttributes,
forwardRef,
useCallback,
useEffect,
useRef,
} from 'react'
import { TICK_MS, useLoopHandler } from './PhysicsContext'
export interface ComicImageProps
extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet'> {
img: typeof import('*.png').default
alt?: string
}
export const ComicImage = forwardRef<HTMLImageElement, ComicImageProps>(
function ComicImage({ img, alt = '', ...props }, ref) {
return (
<img
{...props}
ref={ref}
src={img.url['2x']}
srcSet={img.srcSet}
width={img.width}
height={img.height}
alt={alt}
draggable="false"
/>
)
},
)
export const ComicImageAnimation = function ComicImageAnimation({
imgs,
rateMs = 0,
showIdx = null,
...props
}: {
imgs: Array<typeof import('*.png').default>
rateMs?: number
showIdx?: number | null
animate?: boolean
} & HTMLAttributes<HTMLDivElement>) {
const ref = useRef<HTMLImageElement>(null)
const lastIdx = useRef<number | null>(null)
const updateIdx = useCallback((idx: number) => {
if (idx === lastIdx.current) {
return
}
const { current: el } = ref
if (!el) {
return
}
if (lastIdx.current != null) {
el.children[lastIdx.current].setAttribute('style', 'opacity: 0')
}
el.children[idx].setAttribute('style', 'opacity: 1')
lastIdx.current = idx
}, [])
useEffect(() => {
if (showIdx != null) {
updateIdx(showIdx)
return
} else if (lastIdx.current == null) {
// On first render, always show first frame, even if paused.
updateIdx(0)
}
}, [imgs, rateMs, updateIdx, showIdx])
const ticksPerFrame = rateMs / TICK_MS
const isStatic = showIdx != null || rateMs === 0
useLoopHandler(
({ getCurrentTick }) => {
if (isStatic) {
return
}
const currentTick = getCurrentTick()
updateIdx(Math.floor(currentTick / ticksPerFrame) % imgs.length)
},
[imgs.length, isStatic, ticksPerFrame, updateIdx],
!isStatic,
)
return (
<div
ref={ref}
css={{
position: 'relative',
width: imgs[0].width,
height: imgs[0].height,
img: {
position: 'absolute',
left: 0,
top: 0,
opacity: 0,
isolation: 'isolate',
},
contain: 'strict',
}}
{...props}
>
{imgs.map((img, idx) => (
<ComicImage key={idx} img={img} />
))}
</div>
)
}
================================================
FILE: client/src/components/ComicPuzzleView.tsx
================================================
import { AnimatePresence } from 'framer-motion'
import { useCallback, useMemo, useRef, useState } from 'react'
import invariant from 'tiny-invariant'
import { emptyWidgets } from '../page/fixtures/emptyMachine'
import { Bounds, PuzzleOrder, WidgetCollection } from '../types'
import LoadingSpinner from './LoadingSpinner'
import { MachineContextProvider } from './MachineContext'
import {
MachineTileContextProvider,
MachineTileContextProviderRef,
} from './MachineTileContext'
import MachineTileEditor from './MachineTileEditor'
import { NamePrompt } from './NamePrompt'
import { PhysicsContextProvider, PhysicsLoader } from './PhysicsContext'
import {
MetaMachineInfo,
SavedMachine,
useSubmitBlueprint,
} from './useMetaMachineClient'
function saveSolution(location: {
blueprintId: string
xt: number
yt: number
}) {
try {
const existingValue = JSON.parse(
localStorage.getItem('contraptions') ?? '[]',
) as Array<unknown>
localStorage['contraptions'] = JSON.stringify([...existingValue, location])
} catch (err) {
console.warn(
`Submitted ${location.blueprintId}, failed to save to localStorage`,
err,
)
}
}
export function ComicPuzzleView({
puzzle,
metaMachine,
isActive,
onSubmit,
}: {
puzzle: PuzzleOrder
metaMachine: MetaMachineInfo | null
isActive: boolean
onSubmit: (location: SavedMachine) => void
}) {
const machineTileRef = useRef<MachineTileContextProviderRef>()
const submitBlueprint = useSubmitBlueprint()
const [isNaming, setNaming] = useState(false)
const [widgets, setWidgets] = useState<WidgetCollection | null>(null)
const handleSubmit = useCallback((widgets: WidgetCollection) => {
setWidgets(widgets)
setNaming(true)
}, [])
const handleCancelName = useCallback(() => {
setNaming(false)
}, [])
const handleSend = useCallback(
(title: string) => {
async function doSubmit() {
invariant(widgets, 'widgets cannot be null')
invariant(puzzle, 'puzzle cannot be null')
// TODO: spinner
const location = await submitBlueprint.mutateAsync({
puzzleId: puzzle.id,
workOrder: puzzle.workOrder,
title,
widgets,
})
if (location) {
const snapshot = machineTileRef.current?.snapshot()
onSubmit({ ...location, title, widgets, puzzle, snapshot })
saveSolution(location)
}
}
void doSubmit()
},
[onSubmit, puzzle, submitBlueprint, widgets],
)
const tileBounds: Bounds | null = useMemo(
() =>
metaMachine
? [0, 0, metaMachine.tileWidth, metaMachine.tileHeight]
: null,
[metaMachine],
)
if (!metaMachine || !puzzle || !tileBounds) {
return <LoadingSpinner />
}
return (
<PhysicsContextProvider stepRateMultiplier={isActive ? 1 : 0}>
<PhysicsLoader spinner={<LoadingSpinner />}>
<MachineContextProvider
initialSimulationBounds={tileBounds}
initialViewBounds={tileBounds}
msPerBall={metaMachine.msPerBall}
>
<MachineTileContextProvider ref={machineTileRef} bounds={tileBounds}>
<AnimatePresence>
{isNaming ? (
<NamePrompt onSubmit={handleSend} onCancel={handleCancelName} />
) : null}
</AnimatePresence>
<MachineTileEditor
key={puzzle.id}
puzzle={puzzle}
initialWidgets={emptyWidgets}
onSubmit={handleSubmit}
/>
</MachineTileContextProvider>
</MachineContextProvider>
</PhysicsLoader>
</PhysicsContextProvider>
)
}
================================================
FILE: client/src/components/DebugOverlay.tsx
================================================
import { useContext, useRef } from 'react'
import { coords } from '../lib/coords'
import { MachineTileContext } from './MachineTileContext'
import { useLoopHandler } from './PhysicsContext'
export default function DebugOverlay({
ticksBetweenUpdates = 0,
}: {
ticksBetweenUpdates?: number
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const { width, height } = useContext(MachineTileContext)
const ticksRef = useRef<number | null>(null)
useLoopHandler(
({ world }) => {
const context = canvasRef.current?.getContext('2d')
if (!context) {
return
}
const { current: ticksSinceUpdate } = ticksRef
if (
ticksBetweenUpdates > 0 &&
ticksSinceUpdate != null &&
ticksSinceUpdate < ticksBetweenUpdates
) {
ticksRef.current = ticksSinceUpdate + 1
return
}
context.clearRect(0, 0, width, height)
const { vertices, colors } = world.debugRender()
for (let i = 0; i < vertices.length / 4; i += 1) {
const red = colors[i * 4 + 0] * 255
const green = colors[i * 4 + 1] * 255
const blue = colors[i * 4 + 2] * 255
const alpha = colors[i * 4 + 3] * 255
context.beginPath()
context.strokeStyle = `rgba(${red}, ${green}, ${blue}, ${alpha})`
context.lineWidth = 1.5
context.moveTo(
...coords.fromRapier.vector(vertices[i * 4 + 0], vertices[i * 4 + 1]),
)
context.lineTo(
...coords.fromRapier.vector(vertices[i * 4 + 2], vertices[i * 4 + 3]),
)
context.closePath()
context.stroke()
}
ticksRef.current = 0
},
[width, height, ticksRef, ticksBetweenUpdates],
)
return (
<div
css={{
position: 'relative',
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 100,
}}
>
<canvas ref={canvasRef} height={width} width={height} />
</div>
)
}
================================================
FILE: client/src/components/EditorTutorials.tsx
================================================
import imgTutorialCongrats from '@art/tutorial_congrats_4x.png'
import imgTutorialExpiry from '@art/tutorial_expiry_4x.png'
import imgTutorialRouted from '@art/tutorial_routed_4x.png'
import { AnimatePresence } from 'framer-motion'
import { useCallback, useMemo, useState } from 'react'
import { ComicImage } from './ComicImage'
import { SwooshyDialog } from './SwooshyDialog'
const tutorials = {
routed: imgTutorialRouted,
expiry: imgTutorialExpiry,
submit: imgTutorialCongrats,
} as const
const tutorialKeys = Object.keys(tutorials)
type TutorialKey = keyof typeof tutorials
type TutorialState = { [K in TutorialKey]: boolean }
const STORE_KEY = 'contraptionTips'
function loadState(): TutorialState {
let data: Record<string, boolean> = {}
try {
const stored = localStorage.getItem(STORE_KEY)
data = JSON.parse(stored ?? '{}') as Record<string, boolean>
} catch {
// Use defaults
}
return Object.fromEntries(
tutorialKeys.map((key) => [key, Boolean(data[key]) ?? false]),
) as TutorialState
}
function saveState(state: TutorialState) {
localStorage[STORE_KEY] = JSON.stringify(state)
}
export function useTutorials() {
const [seenTutorials, setSeenTutorials] = useState(loadState)
const [visibleTutorial, setVisibleTutorial] = useState<TutorialKey | null>(
null,
)
const showTutorial = useCallback(
(key: TutorialKey) => {
if (seenTutorials[key] || visibleTutorial != null) {
return
}
setVisibleTutorial(key)
},
[seenTutorials, visibleTutorial],
)
const dismissTutorial = useCallback(
(key: TutorialKey) => {
setVisibleTutorial(null)
const nextState = { ...seenTutorials, [key]: true }
setSeenTutorials(nextState)
saveState(nextState)
},
[seenTutorials],
)
return useMemo(
() => ({ seenTutorials, visibleTutorial, showTutorial, dismissTutorial }),
[dismissTutorial, seenTutorials, showTutorial, visibleTutorial],
)
}
export function EditorTutorials({
visibleTutorial,
onDismissTutorial,
}: {
visibleTutorial: TutorialKey | null
onDismissTutorial: (key: TutorialKey) => void
}) {
const handleDismiss = useCallback(() => {
if (!visibleTutorial) {
return
}
onDismissTutorial(visibleTutorial)
}, [onDismissTutorial, visibleTutorial])
return (
<AnimatePresence>
{visibleTutorial ? (
<SwooshyDialog onDismiss={handleDismiss}>
<div
css={{
display: 'flex',
position: 'relative',
}}
>
<ComicImage
img={tutorials[visibleTutorial]}
css={{
filter: 'drop-shadow(5px 5px 0 rgba(0, 0, 0, 0.5))',
}}
/>
<button
css={{
position: 'absolute',
background: 'none',
border: 'none',
top: 0,
right: 0,
width: 24,
height: 24,
cursor: 'pointer',
}}
onClick={handleDismiss}
aria-label="Close"
/>
</div>
</SwooshyDialog>
) : null}
</AnimatePresence>
)
}
================================================
FILE: client/src/components/FullscreenComicContainer.tsx
================================================
import useSize from '@react-hook/size'
import {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import comic from '../../comic.json'
import InnerComicBorder from './InnerComicBorder'
import { SwooshyDialog } from './SwooshyDialog'
function useIsFullscreen() {
const [isFullscreen, setFullscreen] = useState(false)
useEffect(() => {
function handleFullscreenChange() {
setFullscreen(
Boolean(document.fullscreenElement ?? document.webkitFullscreenElement),
)
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
}
})
return isFullscreen
}
export type DisplayContextType = {
isFullscreen: boolean
orientation: 'portrait' | 'landscape'
}
export const DisplayContext = createContext<DisplayContextType>({
isFullscreen: false,
orientation: 'portrait',
})
export function useDisplayState() {
return useContext(DisplayContext)
}
export function FullscreenComicContainer({
children,
}: {
children: ReactNode
}) {
const ref = useRef<HTMLDivElement>(null)
const [canFullscreen, setCanFullscreen] = useState(false)
const [fullscreenFailed, setFullscreenFailed] = useState(false)
const [width, height] = useSize(ref)
const isFullscreen = useIsFullscreen()
const [isTouch] = useState(
() => window.matchMedia('(pointer: coarse)').matches,
)
useEffect(() => {
const { current: el } = ref
if (!el) {
return
}
setCanFullscreen(
'requestFullscreen' in el || 'webkitRequestFullscreen' in el,
)
}, [])
const handleMaybeFullscreen = useCallback(() => {
const { current: el } = ref
async function tryFullscreen() {
if (!el) {
return
}
if (
document.fullscreenElement !== el &&
document.webkitFullscreenElement !== el
) {
try {
if ('requestFullscreen' in el) {
await el.requestFullscreen?.()
} else if ('webkitRequestFullscreen' in el) {
await el.webkitRequestFullscreen?.()
}
} catch {
setFullscreenFailed(true)
}
}
}
void tryFullscreen()
}, [])
const comicScale = Math.min(width / comic.width, height / comic.height)
return (
<div
ref={ref}
css={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'white',
}}
>
<InnerComicBorder
style={
comicScale !== 1
? {
transform: `scale(${comicScale})`,
}
: undefined
}
>
{isTouch && !isFullscreen && !fullscreenFailed && canFullscreen && (
<SwooshyDialog
css={{
zIndex: 100,
}}
onDismiss={handleMaybeFullscreen}
>
<button
onClick={handleMaybeFullscreen}
css={{
width: 365,
padding: 16,
fontFamily: 'xkcd-Regular-v3',
fontSize: 50,
background: 'rgba(0, 0, 0, .75)',
color: 'white',
border: 'none',
borderRadius: 16,
}}
>
tap to enter fullscreen
</button>
</SwooshyDialog>
)}
<DisplayContext.Provider
value={{
isFullscreen,
orientation: height > width ? 'portrait' : 'landscape',
}}
>
{children}
</DisplayContext.Provider>
</InnerComicBorder>
</div>
)
}
================================================
FILE: client/src/components/InnerComicBorder.tsx
================================================
import { CSSProperties, ReactNode } from 'react'
import comic from '../../comic.json'
export default function InnerComicBorder({
style,
children,
}: {
style?: CSSProperties
children: ReactNode
}) {
return (
<div
css={{
position: 'relative',
width: comic.width,
height: comic.height,
'&:after': {
content: '""',
position: 'absolute',
border: '2px solid black',
inset: 0,
zIndex: 100,
pointerEvents: 'none',
},
}}
style={style}
>
{children}
</div>
)
}
================================================
FILE: client/src/components/LoadingSpinner.tsx
================================================
import { ComicImage } from './ComicImage'
import ballBlueImg from '@art/ball-blue_4x.png'
import ballGreenImg from '@art/ball-green_4x.png'
import ballRedImg from '@art/ball-red_4x.png'
import ballYellowImg from '@art/ball-yellow_4x.png'
import { css, keyframes } from '@emotion/react'
const rotate = keyframes`
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
`
const fadeIn = keyframes`
0% {
opacity: 0;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
`
const counterRotateStyle = css({
animation: `${rotate} 1s linear infinite reverse`,
})
export default function LoadingSpinner({ className }: { className?: string }) {
return (
<div
css={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
}}
className={className}
>
<div
css={{
position: 'absolute',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 4,
margin: 4,
animation: `1s linear infinite ${rotate}, 500ms ease-in ${fadeIn}`,
}}
>
<ComicImage css={counterRotateStyle} img={ballBlueImg} />
<ComicImage css={counterRotateStyle} img={ballGreenImg} />
<ComicImage css={counterRotateStyle} img={ballRedImg} />
<ComicImage css={counterRotateStyle} img={ballYellowImg} />
</div>
</div>
)
}
================================================
FILE: client/src/components/MachineContext.tsx
================================================
import { noop } from 'lodash'
import mitt, { Emitter } from 'mitt'
import {
MutableRefObject,
ReactNode,
createContext,
forwardRef,
useCallback,
useContext,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import type { RigidBody } from '@dimforge/rapier2d'
import { coords } from '../lib/coords'
import { BallSnapshot, BodySnapshot, snapshotBody } from '../lib/snapshot'
import { inBounds, useIdGen } from '../lib/utils'
import { BallType, Bounds } from '../types'
import { PhysicsContext } from './PhysicsContext'
export interface BallData {
id: string
snapshot: BodySnapshot
age: number
type: BallType
overrideDamping?: number
}
export type BallDestroyReason = 'expiry'
export type BallFollowCallback = (x: number, y: number) => void
type MachineEvents = {
createBall: BallData
destroyBall: string
exitBall: string
renewBall: string
killBall: string
followBall: BallFollowCallback
unfollowBall: void
ballExpired: void
}
type ActiveBalls = Record<
string,
{
type: BallType
body: RigidBody
renewTick: number
}
>
export type CreateBallOptions = {
vx?: number
vy?: number
overrideDamping?: number
}
export type MachineContextType = {
msPerBall: number
createBall: (
x: number,
y: number,
type: BallType,
opts?: CreateBallOptions,
) => void
destroyBall: (id: string, reason?: BallDestroyReason) => void
/** Destroy the ball when it leaves the viewable area */
exitBall: (id: string) => void
renewBall: (id: string) => void
killBall: (id: string) => void
clearBalls: () => void
followBall: (callback: BallFollowCallback) => void
unfollowBall: () => void
registerBall: (
id: string,
type: BallType,
body: RigidBody,
renewTick: number,
) => void
unregisterBall: (id: string) => void
snapshotBalls: (bounds: Bounds) => BallSnapshot[]
restoreBalls: (ballSnapshots: BallSnapshot[]) => void
simulationBoundsRef: MutableRefObject<Bounds>
viewBoundsRef: MutableRefObject<Bounds>
events: Emitter<MachineEvents>
}
export interface MachineContextProviderRef {
createBall: MachineContextType['createBall']
clearBalls: MachineContextType['clearBalls']
followBall: MachineContextType['followBall']
unfollowBall: MachineContextType['unfollowBall']
events: MachineContextType['events']
simulationBoundsRef: MachineContextType['simulationBoundsRef']
viewBoundsRef: MachineContextType['viewBoundsRef']
}
const infiniteBounds: Bounds = [-Infinity, -Infinity, Infinity, Infinity]
export const MachineContext = createContext<MachineContextType>({
msPerBall: Infinity,
createBall: noop,
destroyBall: noop,
exitBall: noop,
renewBall: noop,
killBall: noop,
followBall: noop,
unfollowBall: noop,
clearBalls: noop,
registerBall: noop,
unregisterBall: noop,
snapshotBalls: () => [],
restoreBalls: noop,
simulationBoundsRef: { current: infiniteBounds },
viewBoundsRef: { current: infiniteBounds },
events: mitt(),
})
export const MachineContextProvider = forwardRef(
function MachineContextProvider(
{
msPerBall,
initialSimulationBounds = infiniteBounds,
initialViewBounds = infiniteBounds,
children,
}: {
msPerBall: number
initialSimulationBounds?: Bounds
initialViewBounds?: Bounds
children: ReactNode
},
ref,
) {
const physics = useContext(PhysicsContext)
const nextBallId = useIdGen()
const [events] = useState(() => mitt<MachineEvents>())
const activeBalls = useRef<ActiveBalls>({})
const createBall = useCallback(
(
x: number,
y: number,
type: BallType,
{ vx = 0, vy = 0, overrideDamping }: CreateBallOptions = {},
) => {
events.emit('createBall', {
id: nextBallId(),
snapshot: {
x: coords.toRapier.x(x),
y: coords.toRapier.y(y),
angle: 0,
vx,
vy,
va: 0,
},
age: 0,
type,
overrideDamping,
})
},
[events, nextBallId],
)
const destroyBall = useCallback(
(id: string, reason?: BallDestroyReason) => {
events.emit('destroyBall', id)
if (reason === 'expiry') {
events.emit('ballExpired')
}
},
[events],
)
const exitBall = useCallback(
(id: string) => {
events.emit('exitBall', id)
},
[events],
)
const renewBall = useCallback(
(id: string) => {
events.emit('renewBall', id)
},
[events],
)
const killBall = useCallback(
(id: string) => {
events.emit('killBall', id)
},
[events],
)
const followBall = useCallback(
(callback: BallFollowCallback) => {
events.emit('followBall', callback)
},
[events],
)
const unfollowBall = useCallback(() => {
events.emit('unfollowBall')
}, [events])
const clearBalls = useCallback(() => {
Object.keys(activeBalls.current).forEach((id) => destroyBall(id))
}, [destroyBall])
const registerBall = useCallback(
(id: string, type: BallType, body: RigidBody, renewTick: number) => {
activeBalls.current[id] = { type, body, renewTick }
},
[],
)
const unregisterBall = useCallback((id: string) => {
delete activeBalls.current[id]
}, [])
const snapshotBalls = useCallback(
(bounds: Bounds) => {
if (!physics) {
return []
}
const currentTick = physics.getCurrentTick()
return Object.values(activeBalls.current)
.filter(({ body }) => {
const { x, y } = body.translation()
return inBounds(x, y, bounds)
})
.map(({ type, body, renewTick }) => ({
...snapshotBody(body),
age: currentTick - renewTick,
type,
}))
},
[physics],
)
const restoreBalls = useCallback(
(ballSnapshots: BallSnapshot[]) => {
for (const { type, age, ...snapshot } of ballSnapshots) {
events.emit('createBall', {
id: nextBallId(),
snapshot,
age,
type,
})
}
},
[events, nextBallId],
)
const simulationBoundsRef = useRef<Bounds>(initialSimulationBounds)
const viewBoundsRef = useRef<Bounds>(initialViewBounds)
const contextValue: MachineContextType = useMemo(
() => ({
msPerBall,
createBall,
destroyBall,
exitBall,
renewBall,
killBall,
followBall,
unfollowBall,
clearBalls,
registerBall,
unregisterBall,
snapshotBalls,
restoreBalls,
simulationBoundsRef,
viewBoundsRef,
events,
}),
[
msPerBall,
createBall,
destroyBall,
exitBall,
renewBall,
killBall,
followBall,
unfollowBall,
clearBalls,
registerBall,
unregisterBall,
snapshotBalls,
restoreBalls,
events,
],
)
useImperativeHandle(ref, () => ({
createBall,
followBall,
unfollowBall,
clearBalls,
events,
simulationBoundsRef,
viewBoundsRef,
}))
return (
<MachineContext.Provider value={contextValue}>
{children}
</MachineContext.Provider>
)
},
)
export function useMachine() {
return useContext(MachineContext)
}
================================================
FILE: client/src/components/MachineTileContext.tsx
================================================
import { mapValues, noop } from 'lodash'
import {
DependencyList,
ReactNode,
createContext,
forwardRef,
useCallback,
useContext,
useImperativeHandle,
useMemo,
useRef,
} from 'react'
import type RAPIER from '@dimforge/rapier2d'
import type {
Collider,
ColliderDesc,
RigidBody,
RigidBodyDesc,
} from '@dimforge/rapier2d'
import { coords } from '../lib/coords'
import {
MachineSnapshot,
applySnapshotToBody,
offsetSnapshot,
snapshotBody,
} from '../lib/snapshot'
import { inBounds } from '../lib/utils'
import { Bounds, isBall } from '../types'
import { useMachine } from './MachineContext'
import { useLoopHandler, useRapierEffect } from './PhysicsContext'
type ActiveBodies = Record<string, RigidBody>
export type MachineTileContextType = {
bounds: Bounds
width: number
height: number
registerBody: (key: string, body: RigidBody) => void
unregisterBody: (key: string) => void
snapshot: () => MachineSnapshot
loadSnapshot: (snapshot: MachineSnapshot) => void
}
export interface MachineTileContextProviderRef {
snapshot: MachineTileContextType['snapshot']
loadSnapshot: MachineTileContextType['loadSnapshot']
}
export const MachineTileContext = createContext<MachineTileContextType>({
bounds: [0, 0, 0, 0],
width: 0,
height: 0,
registerBody: noop,
unregisterBody: noop,
snapshot: () => ({ widgets: {}, balls: [] }),
loadSnapshot: noop,
})
export const MachineTileContextProvider = forwardRef(
function MachineTileContextProvider(
{
bounds,
initialSnapshot,
children,
}: {
bounds: Bounds
initialSnapshot?: MachineSnapshot
children: ReactNode
},
ref,
) {
const { snapshotBalls, restoreBalls, destroyBall } = useMachine()
const [x1, y1, x2, y2] = bounds
const stableBounds = useMemo(() => [x1, y1, x2, y2], [x1, x2, y1, y2])
const width = x2 - x1
const height = y2 - y1
const activeBodies = useRef<ActiveBodies>({})
const registerBody = useCallback((key: string, body: RigidBody) => {
activeBodies.current[key] = body
}, [])
const unregisterBody = useCallback((key: string) => {
delete activeBodies.current[key]
}, [])
const snapshot = useCallback(() => {
const [x1, y1, x2, y2] = stableBounds
const rapierBounds: Bounds = [
// This is tricky: because rapier's y axis grows in the opposite direction of ours, we swap y1 and y2.
...coords.toRapier.vector(x1, y2),
...coords.toRapier.vector(x2, y1),
]
return {
widgets: mapValues(activeBodies.current, (body, key) => ({
...snapshotBody(body),
key,
})),
balls: snapshotBalls(rapierBounds),
}
}, [snapshotBalls, stableBounds])
const loadSnapshot = useCallback(
(snapshot: MachineSnapshot) => {
const [x1, y1] = stableBounds
// Tile snapshots are based on a zero origin. We must offset the translation coordinates to our tile position in rapier space.
const [rapierOffsetX, rapierOffsetY] = coords.toRapier.vector(x1, y1)
for (const [id, widgetSnapshot] of Object.entries(snapshot.widgets)) {
const body = activeBodies.current[id]
if (body) {
applySnapshotToBody(
offsetSnapshot(rapierOffsetX, rapierOffsetY, widgetSnapshot),
body,
)
}
}
restoreBalls(
snapshot.balls.map((snapshot) =>
offsetSnapshot(rapierOffsetX, rapierOffsetY, snapshot),
),
)
},
[restoreBalls, stableBounds],
)
const contextValue = useMemo(
() => ({
bounds,
width,
height,
registerBody,
unregisterBody,
snapshot,
loadSnapshot,
}),
[
bounds,
width,
height,
registerBody,
unregisterBody,
snapshot,
loadSnapshot,
],
)
useImperativeHandle(ref, () => ({
snapshot,
loadSnapshot,
}))
const hasRestoredSnapshot = useRef(false)
useRapierEffect(
({ world, rapier: { Cuboid } }) => {
if (initialSnapshot && !hasRestoredSnapshot.current) {
loadSnapshot(initialSnapshot)
hasRestoredSnapshot.current = true
}
// Clean up balls when tiles removed
return () => {
world.intersectionsWithShape(
coords.toRapier.vectorObject(x1 + width / 2, y1 + height / 2),
0,
new Cuboid(...coords.toRapier.lengths(width / 2, height / 2)),
(collider) => {
const body = collider.parent()
if (!body || !isBall(body)) {
return true
}
destroyBall(body.userData.id)
return true
},
)
}
},
[loadSnapshot, initialSnapshot, x1, width, y1, height, destroyBall],
)
return (
<MachineTileContext.Provider value={contextValue}>
{children}
</MachineTileContext.Provider>
)
},
)
export function useRigidBody(
create: (rapier: typeof RAPIER) => {
key: string | null
bodyDesc: RigidBodyDesc
colliderDescs?: ColliderDesc[]
},
deps: DependencyList,
) {
const { registerBody, unregisterBody } = useContext(MachineTileContext)
const bodyRef = useRef<RigidBody | null>(null)
useRapierEffect(({ rapier, world }) => {
const { key, bodyDesc, colliderDescs } = create(rapier)
const body = world.createRigidBody(bodyDesc)
bodyRef.current = body
colliderDescs
? colliderDescs.map((colliderDesc) =>
world.createCollider(colliderDesc, body),
)
: null
if (key != null) {
registerBody(key, body)
}
return () => {
if (key != null) {
unregisterBody(key)
}
setTimeout(() => {
world.removeRigidBody(body)
}, 0)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
return bodyRef
}
export function useSensorInTile(
collider: Collider | undefined,
handler: (otherCollider: Collider) => void,
deps: DependencyList,
) {
const { bounds } = useContext(MachineTileContext)
useLoopHandler(
({ world }) => {
if (!collider) {
return
}
world.intersectionPairsWith(collider, (otherCollider) => {
const [x, y] = coords.fromBody.vector(otherCollider)
if (inBounds(x, y, bounds)) {
handler(otherCollider)
}
})
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[collider, ...deps],
)
}
export function useMachineTile() {
return useContext(MachineTileContext)
}
================================================
FILE: client/src/components/MachineTileEditor.tsx
================================================
import imgSubmit from '@art/submit_4x.png'
import imgWrench from '@art/wrench_4x.png'
import { PropsOf } from '@emotion/react'
import useLatest from '@react-hook/latest'
import { motion } from 'framer-motion'
import { max } from 'lodash'
import React, {
KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import Moveable, {
OnDrag,
OnResize,
OnResizeStart,
OnRotate,
} from 'react-moveable'
import { px, useIdGen } from '../lib/utils'
import { Puzzle, WidgetCollection } from '../types'
import { ComicImage } from './ComicImage'
import DebugOverlay from './DebugOverlay'
import { EditorTutorials, useTutorials } from './EditorTutorials'
import { useDisplayState } from './FullscreenComicContainer'
import { useMachine } from './MachineContext'
import { useMachineTile } from './MachineTileContext'
import { useLoopHandler } from './PhysicsContext'
import WidgetPalette, { ToolButton, comicDropShadow } from './WidgetPalette'
import { MAX_WIDGET_COUNT } from './constants'
import { getPositionStyles } from './positionStyles'
import {
PaletteItem,
WidgetData,
Widgets,
stickerList,
widgetList,
} from './widgets'
import { Balls } from './widgets/Balls'
import MachineFrame from './widgets/MachineFrame'
import { getNextWheelSpeed } from './widgets/Wheel'
const DEG_TO_RAD = Math.PI / 180
export interface EditableWidget {
id: string
onSelect?: (
ev: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
id: string,
) => void
isSelected?: boolean
}
interface Selection {
id: string
el: HTMLDivElement
}
export function useSelectHandlers(
id: EditableWidget['id'],
onSelect: EditableWidget['onSelect'],
) {
const handleSelect = useCallback(
(
ev: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
) => {
onSelect?.(ev, id)
},
[id, onSelect],
)
return { onMouseDown: handleSelect, onTouchStart: handleSelect }
}
type Widgets = Record<string, WidgetData>
// use arrow keys for fun and profit
// ... but only when a wheel is selected
export function useWheelSpeed(
setWidgets: (setState: (widgets: Widgets) => Widgets) => void,
selection: Selection | undefined,
) {
// no advantage to recreating everything each time selection changes imo
const selectionRef = useRef(selection)
selectionRef.current = selection
useEffect(() => {
function handleKey(ev: KeyboardEvent) {
const key = ev.key.toLowerCase()
const { current: selection } = selectionRef
if (selection == null) {
return
}
setWidgets((widgets) => {
const selectedWheel = widgets[selection.id]
if (selectedWheel.type === 'spokedwheel') {
const speed = getNextWheelSpeed(selectedWheel, key)
if (speed != null) {
return {
...widgets,
[selection.id]: {
...selectedWheel,
speed,
},
}
}
}
return widgets
})
}
// @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something
window.addEventListener('keydown', handleKey, false)
return () => {
// @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something
window.removeEventListener('keydown', handleKey, false)
}
}, [selectionRef, setWidgets])
}
const dragBounds: PropsOf<typeof Moveable>['bounds'] = {
left: 0,
top: 0,
right: 0,
bottom: 0,
position: 'css',
}
export default function MachineTileEditor({
puzzle,
initialWidgets,
onSubmit,
}: {
puzzle: Puzzle
initialWidgets: WidgetCollection
onSubmit?: (widgets: WidgetCollection) => void
}) {
const { clearBalls, events: machineEvents } = useMachine()
const { width, height } = useMachineTile()
const display = useDisplayState()
const isMobilePalette =
display.isFullscreen && display.orientation === 'portrait'
const moveableRef = useRef<Moveable>(null)
const [isShowingPalette, setShowingPalette] = useState(false)
const [isManipulating, setManipulating] = useState(false)
const [selection, setSelection] = useState<Selection>()
const [isValidOutputs, setValidOutputs] = useState(false)
const nextId = useIdGen(
() => max(Object.keys(initialWidgets).map(Number)) ?? 0,
)
const [widgets, setWidgets] =
useState<Record<string, WidgetData>>(initialWidgets)
const latestWidgets = useLatest(widgets)
const widgetCount = Object.keys(widgets).length
const tutorials = useTutorials()
const canSubmit = isValidOutputs && widgetCount <= MAX_WIDGET_COUNT
useEffect(() => {
function showExpiryTutorial() {
tutorials.showTutorial('expiry')
}
function showRoutedTutorial() {
tutorials.showTutorial('routed')
}
machineEvents.on('ballExpired', showExpiryTutorial)
machineEvents.on('exitBall', showRoutedTutorial)
return () => {
machineEvents.off('ballExpired', showExpiryTutorial)
machineEvents.off('exitBall', showRoutedTutorial)
}
}, [machineEvents, tutorials])
useEffect(() => {
if (canSubmit) {
tutorials.showTutorial('submit')
}
}, [canSubmit, tutorials])
const handleStartManipulating = useCallback(() => {
setManipulating(true)
}, [])
const handleEndManipulating = useCallback(() => {
setManipulating(false)
}, [])
useLoopHandler(() => {
const { current: moveable } = moveableRef
if (!moveable) {
return
}
setTimeout(() => {
moveable.updateRect()
}, 0)
}, [])
const applyImmediateStyles = useCallback(
(
selection: Selection,
{ x, y, angle }: { x?: number; y?: number; angle?: number },
) => {
const curWidget = latestWidgets.current[selection.id]
// Not all widgets have rotation so we have to do some type checks for ts to be happy with this.
const curAngle = 'angle' in curWidget ? curWidget.angle : 0
Object.assign(
selection.el,
getPositionStyles(
x ?? curWidget.x,
y ?? curWidget.y,
angle ?? curAngle,
),
)
},
[latestWidgets],
)
const handleSelect = useCallback(
(
ev: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
id: string,
) => {
async function doSelect() {
const { current: moveable } = moveableRef
if (!moveable) {
return
}
setSelection({ id, el: ev.currentTarget })
await moveable.waitToChangeTarget()
moveable.dragStart(ev.nativeEvent)
}
void doSelect()
},
[setSelection],
)
const handleDrag = useCallback(
({ translate: [x, y] }: OnDrag) => {
if (!selection) {
return
}
applyImmediateStyles(selection, { x, y })
setWidgets((curWidgets) => {
const selectedWidget = curWidgets[selection.id]
return {
...curWidgets,
[selection.id]: {
...selectedWidget,
x,
y,
},
}
})
},
[applyImmediateStyles, selection],
)
const handleRotate = useCallback(
({ rotation: degRotation }: OnRotate) => {
if (!selection) {
return
}
const angle = degRotation * DEG_TO_RAD
applyImmediateStyles(selection, { angle })
setWidgets((curWidgets) => {
const selectedWidget = curWidgets[selection.id]
return {
...curWidgets,
[selection.id]: {
...selectedWidget,
angle,
},
}
})
},
[applyImmediateStyles, selection],
)
const handleResize = useCallback(
({
target,
width,
height,
drag: {
translate: [x, y],
transform,
},
}: OnResize) => {
if (!selection) {
return
}
// It's necessary to immediately apply the styles, otherwise react-moveable gets stale values and glitches out.
target.style.transform = transform
target.style.width = px(width)
target.style.height = px(height)
setWidgets((curWidgets) => {
const selectedWidget = curWidgets[selection.id]
return {
...curWidgets,
[selection.id]: {
...selectedWidget,
x,
y,
width,
height,
},
}
})
},
[selection],
)
const handleResizeStart = useCallback((ev: OnResizeStart) => {
ev.setMin([25, 25])
setManipulating(true)
}, [])
const handleDeselect = useCallback((ev: React.MouseEvent<HTMLDivElement>) => {
if (ev.target === ev.currentTarget) {
setSelection(undefined)
setShowingPalette(false)
}
}, [])
const handleOpenPalette = useCallback(() => {
setShowingPalette(true)
}, [])
const handleAddWidget = useCallback(
(create: PaletteItem<WidgetData>['create']) => {
setWidgets((curWidgets) => ({
...curWidgets,
[nextId()]: create(width / 2, height / 2),
}))
},
[height, nextId, width],
)
const handleTrashWidget = useCallback(() => {
if (!selection) {
return
}
setWidgets(({ [selection.id]: _removed, ...curWidgets }) => curWidgets)
setSelection(undefined)
}, [selection])
const [widgetsKey, setWidgetsKey] = useState(0)
const handleEmergencyStop = useCallback(() => {
// Remove and recreate widgets
setWidgetsKey((x) => x + 1)
clearBalls()
}, [clearBalls])
const handleSubmit = useCallback(() => {
onSubmit?.(widgets)
}, [onSubmit, widgets])
const [showDebugOverlay, setShowDebugOverlay] = useState(false)
// Delete key trashes widgets and ctrl+option+shift+d shows the debug overlay
useEffect(() => {
function handleKey(ev: KeyboardEvent) {
if (ev.key === 'Delete') {
handleTrashWidget()
} else if (
ev.key.toLowerCase() === 'd' &&
ev.shiftKey &&
ev.ctrlKey &&
ev.metaKey
) {
setShowDebugOverlay((prev) => !prev)
} else if (ev.key === 'Escape') {
setShowDebugOverlay(false)
}
}
// @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something
window.addEventListener('keydown', handleKey, false)
return () => {
// @ts-expect-error typescript doesn't like going from event to KeyboardEvent or something
window.removeEventListener('keydown', handleKey, false)
}
}, [handleTrashWidget, selection, setShowDebugOverlay])
useWheelSpeed(setWidgets, selection)
const selectedWidget = selection ? widgets[selection.id] : null
const selectedWidgetInfo =
selectedWidget == null
? null
: selectedWidget.type === 'sticker'
? stickerList[selectedWidget.sticker]
: widgetList[selectedWidget.type]
return (
<div
onMouseDown={handleDeselect}
css={{
position: 'relative',
width,
height,
overflow: 'hidden',
userSelect: 'none',
}}
>
<EditorTutorials
visibleTutorial={tutorials.visibleTutorial}
onDismissTutorial={tutorials.dismissTutorial}
/>
{/* portal so can display outside extents? */}
<Moveable
ref={moveableRef}
css={{
'.moveable-control.moveable-origin': {
display: 'none',
},
}}
target={selection?.el}
onDrag={handleDrag}
onRotate={handleRotate}
onResize={handleResize}
onResizeStart={handleResizeStart}
onDragStart={handleStartManipulating}
onDragEnd={handleEndManipulating}
onRotateStart={handleStartManipulating}
onRotateEnd={handleEndManipulating}
onResizeEnd={handleEndManipulating}
keepRatio={selectedWidgetInfo?.isSquare}
rotatable={selectedWidgetInfo?.canRotate}
resizable={selectedWidgetInfo?.canResize}
bounds={dragBounds}
draggable
snappable
/>
<Widgets
key={widgetsKey}
widgets={widgets}
onSelect={handleSelect}
selectedId={selection?.id}
/>
<Balls />
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
>
<MachineFrame
inputs={puzzle.inputs}
outputs={puzzle.outputs}
onValidate={setValidOutputs}
spawnBallsTop
spawnBallsLeft
spawnBallsRight
validateOutputs
/>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5, duration: 1 }}
>
{!isMobilePalette && (
<ToolButton
onClick={handleOpenPalette}
css={[
{
position: 'absolute',
right: 10,
top: 10,
opacity: isShowingPalette ? 0 : 1,
transition: 'opacity 100ms ease-out',
},
comicDropShadow,
]}
aria-label="Toolbox"
>
<ComicImage img={imgWrench} />
</ToolButton>
)}
<ToolButton
initial={false}
animate={{
scale:
tutorials.seenTutorials.submit ||
tutorials.visibleTutorial === 'submit'
? 1
: 0,
opacity: canSubmit ? 1 : 0.5,
}}
onClick={handleSubmit}
disabled={!canSubmit}
aria-label="Submit your blueprint"
css={[
{
position: 'absolute',
left: 10,
top: 10,
width: 50,
height: 50,
cursor: canSubmit ? 'pointer' : 'not-allowed',
},
comicDropShadow,
]}
>
<ComicImage img={imgSubmit} />
</ToolButton>
</motion.div>
<WidgetPalette
onAdd={handleAddWidget}
onTrash={handleTrashWidget}
onEmergencyStop={handleEmergencyStop}
widgetCount={widgetCount}
isHorizontal={isMobilePalette}
css={
isMobilePalette
? {
position: 'fixed',
bottom: -116,
height: 100,
left: 8,
right: 8,
}
: {
position: 'absolute',
right: 10,
top: 10,
bottom: 10,
width: 72,
transform: isShowingPalette
? ''
: `translateX(calc(100% + 10px))`,
opacity: isManipulating ? 0.1 : 1,
transition: 'transform 250ms ease, opacity 100ms ease-out',
pointerEvents: isManipulating ? 'none' : 'all',
}
}
/>
{showDebugOverlay && <DebugOverlay />}
</div>
)
}
================================================
FILE: client/src/components/MachineTilePlaceholder.tsx
================================================
import imgConstruction1 from '@art/construction-1_4x.png'
import imgConstruction2 from '@art/construction-2_4x.png'
import imgConstruction3 from '@art/construction-3_4x.png'
import imgConstruction4 from '@art/construction-4_4x.png'
import imgConstruction5 from '@art/construction-5_4x.png'
import imgConstruction6 from '@art/construction-6_4x.png'
import imgConstruction7 from '@art/construction-7_4x.png'
import imgConstruction8 from '@art/construction-8_4x.png'
import imgConstruction9 from '@art/construction-9_4x.png'
import { random, sampleSize } from 'lodash'
import { useMemo } from 'react'
import { coords } from '../lib/coords'
import { isBall } from '../types'
import { ComicImage } from './ComicImage'
import { useMachine } from './MachineContext'
import { useCollider, useCollisionHandler } from './PhysicsContext'
import { BALL_RADIUS } from './constants'
const imgConstructionChoices = [
imgConstruction1,
imgConstruction2,
imgConstruction3,
imgConstruction4,
imgConstruction5,
imgConstruction6,
imgConstruction7,
imgConstruction8,
imgConstruction9,
]
export default function MachineTilePlaceholder({
tileWidth,
tileHeight,
xt,
yt,
}: {
tileWidth: number
tileHeight: number
xt: number
yt: number
}) {
const { destroyBall } = useMachine()
// If balls exit finished tiles into the placeholder, destroy them.
const ballCollider = useCollider(
({ ColliderDesc, ActiveEvents }) =>
ColliderDesc.cuboid(
...coords.toRapier.lengths(
tileWidth / 2 - BALL_RADIUS * 2,
tileHeight / 2 - BALL_RADIUS * 2,
),
)
.setTranslation(
...coords.toRapier.vector(
xt * tileWidth + tileWidth / 2,
yt * tileHeight + tileHeight / 2,
),
)
.setSensor(true)
.setActiveEvents(ActiveEvents.COLLISION_EVENTS),
[tileHeight, tileWidth, xt, yt],
)
useCollisionHandler(
'start',
ballCollider,
(otherCollider) => {
const body = otherCollider.parent()
if (!body) {
return
}
if (isBall(body)) {
destroyBall(body.userData.id)
}
},
[],
)
const imgs = useMemo(
() => sampleSize(imgConstructionChoices, random(2, 3)),
[],
)
return (
<div
css={{
position: 'absolute',
background: 'white',
left: xt * tileWidth,
top: yt * tileHeight,
width: tileWidth,
height: tileHeight,
border: '1px solid black',
boxSizing: 'border-box',
pointerEvents: 'none',
zIndex: 20,
}}
>
{imgs.map((img, idx) => (
<ComicImage
key={idx}
img={img}
css={{
position: 'absolute',
left: 0,
top: 0,
}}
/>
))}
</div>
)
}
================================================
FILE: client/src/components/MetaMachineView.tsx
================================================
import { ClassNames } from '@emotion/react'
import { throttle } from 'lodash'
import React, {
RefObject,
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { MachineSnapshot } from '../lib/snapshot'
import { gridViewBounds, iterTiles, tileKey } from '../lib/tiles'
import { Bounds, Puzzle, WidgetCollection } from '../types'
import { BallPitMechanism } from './BallPitMechanism'
import {
CenteredSlippyMap,
CenteredSlippyMapProps,
SlippyMapRef,
} from './CenteredSlippyMap'
import {
MachineContextProvider,
MachineContextProviderRef,
} from './MachineContext'
import { MachineTileContextProvider } from './MachineTileContext'
import MachineTilePlaceholder from './MachineTilePlaceholder'
import { deferATick } from './PhysicsContext'
import { BOTTOM_MECHANISM_HEIGHT } from './constants'
import { MetaMachineInfo } from './useMetaMachineClient'
import { Widgets } from './widgets'
import { Balls } from './widgets/Balls'
import MachineFrame from './widgets/MachineFrame'
const MetaMachineTile = React.memo(function MetaMachineTiles({
puzzle,
widgets,
snapshot,
title,
tileWidth,
tileHeight,
xt,
yt,
spawnBallsTop,
spawnBallsLeft,
spawnBallsRight,
}: {
puzzle: Puzzle
widgets: WidgetCollection
snapshot: MachineSnapshot | undefined
title: string | undefined
tileWidth: number
tileHeight: number
xt: number
yt: number
spawnBallsTop: boolean
spawnBallsLeft: boolean
spawnBallsRight: boolean
}) {
// Stagger tile renders so we don't have entire rows or columns mounting and updating the world state at the same time.
const [isRendered, setRendered] = useState(false)
useEffect(() => {
function draw() {
setRendered(true)
}
const timeout = deferATick(draw)
return () => {
clearTimeout(timeout)
}
}, [])
if (!isRendered) {
return
}
return (
<MachineTileContextProvider
initialSnapshot={snapshot}
bounds={[
xt * tileWidth,
yt * tileHeight,
(xt + 1) * tileWidth,
(yt + 1) * tileHeight,
]}
>
<MachineFrame
title={title}
inputs={puzzle.inputs}
outputs={puzzle.outputs}
spawnBallsTop={spawnBallsTop}
spawnBallsLeft={spawnBallsLeft}
spawnBallsRight={spawnBallsRight}
/>
<Widgets widgets={widgets} />
</MachineTileContextProvider>
)
})
export interface SlippyMetaMachineViewProps extends MetaMachineInfo {
initialX?: number
initialY?: number
initialZoom?: number
simulateOutset?: number
onPosition?: CenteredSlippyMapProps['onPosition']
onDragStart?: CenteredSlippyMapProps['onDragStart']
}
export interface SlippyMetaMachineRef {
mapRef: RefObject<SlippyMapRef | undefined>
machineRef: RefObject<MachineContextProviderRef | undefined>
}
export const SlippyMetaMachineView = forwardRef<
SlippyMetaMachineRef,
SlippyMetaMachineViewProps
>(function SlippyMetaMachineView(
{
getMachine,
tilesX,
tilesY,
tileWidth,
tileHeight,
msPerBall,
initialX = 0,
initialY = 0,
initialZoom = 1,
simulateOutset = 150,
onPosition,
onDragStart,
}: SlippyMetaMachineViewProps,
ref,
) {
const mapRef = useRef<SlippyMapRef>(null)
const machineRef = useRef<MachineContextProviderRef>(null)
const [xt1, setXt1] = useState(0)
const [yt1, setYt1] = useState(0)
const [xt2, setXt2] = useState(1)
const [yt2, setYt2] = useState(1)
const totalWidth = tileWidth * tilesX
const totalHeight = tileHeight * tilesY + BOTTOM_MECHANISM_HEIGHT
const [isBottomVisible, setBottomVisible] = useState(false)
const updatePosition = useMemo(
() =>
throttle((viewBounds: Bounds) => {
const { current: machine } = machineRef
if (!machine) {
return
}
machine.viewBoundsRef.current = viewBounds
const [xt1, yt1, xt2, yt2] = gridViewBounds(
viewBounds,
tilesX,
tilesY,
tileWidth,
tileHeight,
simulateOutset,
)
setXt1(xt1)
setYt1(yt1)
setXt2(xt2)
setYt2(yt2)
machine.simulationBoundsRef.current = [
xt1 * tileWidth,
yt1 * tileHeight,
(xt2 + 1) * tileWidth,
(yt2 + 1) * tileHeight,
]
onPosition?.(viewBounds)
const [, , , y2] = viewBounds
setBottomVisible(y2 > totalHeight - BOTTOM_MECHANISM_HEIGHT)
}, 1000 / 60),
[
tilesX,
tilesY,
tileWidth,
tileHeight,
simulateOutset,
onPosition,
totalHeight,
],
)
useImperativeHandle(ref, () => ({ mapRef, machineRef }))
return (
<ClassNames>
{({ css }) => (
<CenteredSlippyMap
ref={mapRef}
width={tileWidth}
height={tileHeight}
totalWidth={totalWidth}
totalHeight={totalHeight}
innerClassName={css({ outline: '1px solid black' })}
onPosition={updatePosition}
onDragStart={onDragStart}
initialX={initialX}
initialY={initialY}
initialZoom={initialZoom}
>
<MachineContextProvider ref={machineRef} msPerBall={msPerBall}>
{Array.from(iterTiles(xt1, yt1, xt2, yt2), ([xt, yt]) => {
const data = getMachine(xt, yt)
if (!data) {
return (
<MachineTilePlaceholder
key={tileKey(xt, yt)}
tileWidth={tileWidth}
tileHeight={tileHeight}
xt={xt}
yt={yt}
/>
)
}
const { blueprintId, title, puzzle, widgets, snapshot } = data
return (
<MetaMachineTile
key={`${tileKey(xt, yt)}-${blueprintId}`}
puzzle={puzzle}
widgets={widgets}
snapshot={snapshot}
title={title}
tileWidth={tileWidth}
tileHeight={tileHeight}
xt={xt}
yt={yt}
spawnBallsTop={yt === yt1 || !getMachine(xt, yt - 1)}
spawnBallsLeft={xt === xt1 || !getMachine(xt - 1, yt)}
spawnBallsRight={xt === xt2 || !getMachine(xt + 1, yt)}
/>
)
})}
<BallPitMechanism
tileWidth={tileWidth}
tileHeight={tileHeight}
tilesX={tilesX}
tilesY={tilesY}
stepRateMultiplier={isBottomVisible ? 1 : 0.05}
/>
<Balls />
</MachineContextProvider>
</CenteredSlippyMap>
)}
</ClassNames>
)
})
================================================
FILE: client/src/components/NamePrompt.tsx
================================================
import { css } from '@emotion/react'
import { sample } from 'lodash'
import React, { useCallback, useMemo, useState } from 'react'
import { SwooshyDialog, dialogStyles } from './SwooshyDialog'
const superlatives = [
'illustrious',
'magnificent',
'peculiar',
'fascinating',
'marvelous',
'confounding',
]
const nouns = ['invention', 'device', 'machine', 'contraption']
const namePromptStyles = css({
fontFamily: 'xkcd-Regular-v3',
padding: 16,
userSelect: 'none',
display: 'flex',
flexDirection: 'column',
gap: 16,
width: 350,
'.title': {
fontSize: '20px',
lineHeight: '135%',
},
'button, input': {
fontFamily: 'xkcd-Regular-v3',
border: '2px solid black',
padding: 8,
borderRadius: 4,
},
input: {
fontSize: '20px',
background: '#eee',
},
button: {
fontSize: '16px',
background: 'white',
boxShadow: '3px 3px 0 rgba(0, 0, 0, 0.5)',
cursor: 'pointer',
'&:active': {
transform: 'translate(3px, 3px)',
background: '#eee',
boxShadow: 'none',
},
'&:disabled': {
pointerEvents: 'none',
},
},
})
export function NamePrompt({
onSubmit,
onCancel,
}: {
onSubmit: (name: string) => void
onCancel: () => void
}) {
const description = useMemo(
() => `${sample(superlatives)} ${sample(nouns)}`,
[],
)
const [name, setName] = useState('')
const handleNameChange = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setName(ev.target.value)
},
[],
)
const handleSubmit = useCallback(
(ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault()
onSubmit(name)
},
[name, onSubmit],
)
return (
<SwooshyDialog onDismiss={onCancel}>
<form
className="wrapper"
css={[dialogStyles, namePromptStyles]}
onSubmit={handleSubmit}
>
<span className="title">
What will you name this
<br />
{description}?
</span>
<input
css={{
textAlign: 'center',
fontSize: '20px',
}}
value={name}
onChange={handleNameChange}
autoFocus
/>
<div
css={{
display: 'flex',
justifyContent: 'center',
gap: 12,
}}
>
<button onClick={onCancel} type="button">
Cancel
</button>
<button disabled={name.length === 0} type="submit">
Submit
</button>
</div>
</form>
</SwooshyDialog>
)
}
================================================
FILE: client/src/components/PhysicsContext.tsx
================================================
import type RAPIER from '@dimforge/rapier2d'
import {
EventQueue,
type Collider,
type ColliderDesc,
type ImpulseJoint,
type JointData,
type RigidBody,
type World,
} from '@dimforge/rapier2d'
import useLatest from '@react-hook/latest'
import { random } from 'lodash'
import mitt, { Emitter } from 'mitt'
import {
DependencyList,
EffectCallback,
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { ms } from '../lib/utils'
type LoopEvents = {
step: void
} & {
[K in `collision_start_${number}` | `collision_end_${number}`]: number
}
export const GRAVITY = { x: 0.0, y: -9.81 }
export const TICK_MS = 1000 / 60
export interface PhysicsContextType {
tickMs: number
rapier: typeof RAPIER
world: World
events: Emitter<LoopEvents>
getCurrentTick: () => number
}
export const PhysicsContext = createContext<PhysicsContextType | null>(null)
export function PhysicsContextProvider({
stepRateMultiplier = 1,
debug,
children,
}: {
stepRateMultiplier?: number
debug?: boolean
children: ReactNode
}) {
const [contextValue, setContextValue] = useState<PhysicsContextType | null>(
null,
)
const eventQueueRef = useRef<EventQueue | undefined>()
const baseTimeRef = useRef(0)
const tickCountRef = useRef(0)
const isDebugRef = useLatest(debug)
const getCurrentTick = useCallback(() => tickCountRef.current, [])
// TODO: pause when page inactive
useEffect(() => {
let destroyed = false
let world: World | undefined
async function loadAndInit() {
const rapier = await import('@dimforge/rapier2d')
if (destroyed) {
return
}
const world = new rapier.World(GRAVITY)
world.numSolverIterations = 4
world.timestep = TICK_MS / 1000
const events = mitt<LoopEvents>()
eventQueueRef.current = new rapier.EventQueue(true)
setContextValue({
tickMs: TICK_MS,
rapier,
world,
events,
getCurrentTick,
})
}
void loadAndInit()
return () => {
destroyed = true
world?.free()
}
}, [getCurrentTick, isDebugRef])
const resetBaseTime = useCallback(
(now: number, tickMs: number) => {
const newBaseTime = now - (tickCountRef.current + 1) * tickMs
if (isDebugRef.current) {
console.debug(`Skipping ${newBaseTime - baseTimeRef.current}ms`)
}
baseTimeRef.current = newBaseTime
},
[isDebugRef],
)
useEffect(() => {
if (stepRateMultiplier === 0) {
return
}
let lastTickTime = performance.now()
const tickMs = Math.floor(TICK_MS * (1 / stepRateMultiplier))
resetBaseTime(lastTickTime, tickMs)
let raf: number = -1
function step() {
if (!contextValue) {
return
}
const { current: eventQueue } = eventQueueRef
const { world, events } = contextValue
if (!eventQueue || !world) {
return
}
const now = performance.now()
// Skip forward in case of large pauses.
if (now - lastTickTime > 30 * tickMs) {
resetBaseTime(now, tickMs)
}
const neededTicks = Math.ceil((now - baseTimeRef.current) / tickMs)
while (tickCountRef.current < neededTicks) {
const rapierStart = performance.now()
world.step(eventQueue)
const rapierEnd = performance.now()
const eventQueueCallbackStart = performance.now()
eventQueue.drainCollisionEvents((handle1, handle2, started) => {
const eventName = started ? 'start' : 'end'
events.emit(`collision_${eventName}_${handle1}`, handle2)
events.emit(`collision_${eventName}_${handle2}`, handle1)
})
const eventQueueCallbackEnd = performance.now()
const stepCallbackStart = performance.now()
events.emit('step')
const stepCallbackEnd = performance.now()
if (isDebugRef.current && tickCountRef.current % 60 === 0) {
console.debug({
rapier: ms(rapierEnd - rapierStart),
eventQueue: ms(eventQueueCallbackEnd - eventQueueCallbackStart),
stepCallback: ms(stepCallbackEnd - stepCallbackStart),
bodies: world.bodies.len(),
})
}
tickCountRef.current++
lastTickTime = performance.now()
}
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => {
cancelAnimationFrame(raf)
}
}, [contextValue, isDebugRef, resetBaseTime, stepRateMultiplier])
return (
<PhysicsContext.Provider value={contextValue}>
{children}
</PhysicsContext.Provider>
)
}
export function useRapierEffect(
rapierEffect: (physics: PhysicsContextType) => ReturnType<EffectCallback>,
deps: DependencyList,
) {
const physics = useContext(PhysicsContext)
useEffect(() => {
if (!physics) {
return
}
return rapierEffect(physics)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [physics, ...deps])
}
export function useCollider(
create: (rapier: typeof RAPIER) => ColliderDesc | null,
deps: DependencyList,
) {
// TODO: needed to make this a setState for useCollisionHandler to get collider object updates because of the useRapierEffect updates the ref async. Ideally this would be solved without triggering a render. A JSX component-based approach would be more ergonomic than the hooks here.
const [collider, setCollider] = useState<Collider>()
useRapierEffect(({ rapier, world }) => {
const colliderDesc = create(rapier)
if (!colliderDesc) {
return
}
const collider = world.createCollider(colliderDesc)
setCollider(collider)
return () => {
setTimeout(() => {
world.removeCollider(collider, false)
}, 0)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
return collider
}
// TODO: Test this! It looks right but I haven't tested it <.< >.>
export function useImpulseJoint(
create: (rapier: typeof RAPIER) => JointData | null,
body1Ref: React.MutableRefObject<RigidBody | null>,
body2Ref: React.MutableRefObject<RigidBody | null>,
deps: DependencyList,
) {
const jointRef = useRef<ImpulseJoint | null>(null)
useRapierEffect(
({ rapier, world }) => {
const jointDesc = create(rapier)
const { current: left } = body1Ref
const { current: right } = body2Ref
if (!jointDesc || !left || !right) {
return
}
const joint = world.createImpulseJoint(jointDesc, left, right, false)
jointRef.current = joint
return () => {
setTimeout(() => {
world.removeImpulseJoint(joint, false)
}, 0)
}
},
// don't support changing 'create' since it'll break the deps array on every render =(
// eslint-disable-next-line react-hooks/exhaustive-deps
[deps, body1Ref, body2Ref],
)
return jointRef
}
export function useLoopHandler(
handler: (physics: PhysicsContextType) => void,
deps: DependencyList,
enabled: boolean = true,
) {
useRapierEffect((physics) => {
if (!enabled) {
return
}
const { events } = physics
function triggerHandler() {
handler(physics)
}
events.on('step', triggerHandler)
return () => {
events.off('step', triggerHandler)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
}
export function useCollisionHandler(
event: 'start' | 'end',
collider: Collider | undefined,
handler: (otherCollider: Collider) => void,
deps: DependencyList,
) {
useRapierEffect(
(physics) => {
if (!collider) {
return
}
const { world, events } = physics
function triggerHandler(handle: number) {
const otherCollider = world.getCollider(handle)
handler(otherCollider)
}
const { handle } = collider
events.on(`collision_${event}_${handle}`, triggerHandler)
return () => {
events.off(`collision_${event}_${handle}`, triggerHandler)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[event, collider, ...deps],
)
}
export function usePhysicsLoaded() {
return useContext(PhysicsContext) != null
}
export function PhysicsLoader({
spinner,
children,
}: {
spinner: ReactNode
children: ReactNode
}) {
const isPhysicsLoaded = usePhysicsLoaded()
if (!isPhysicsLoaded) {
return spinner
}
return children
}
export function deferATick(callback: () => void) {
return setTimeout(() => {
callback()
}, random(TICK_MS))
}
================================================
FILE: client/src/components/SwooshyDialog.tsx
================================================
import { css } from '@emotion/react'
import { motion } from 'framer-motion'
import React, { ReactNode, useCallback, useRef } from 'react'
export const dialogStyles = css({
border: '2px solid black',
background: 'white',
boxShadow: '5px 5px 0 rgba(0, 0, 0, 0.5)',
borderRadius: 4,
})
export function SwooshyDialog({
className,
onDismiss,
children,
}: {
className?: string
onDismiss?: () => void
children: ReactNode
}) {
const ref = useRef<HTMLDivElement>(null)
const handleClick = useCallback(
(ev: React.MouseEvent<HTMLDivElement>) => {
if (ev.target === ev.currentTarget) {
onDismiss?.()
}
},
[onDismiss],
)
return (
<motion.div
ref={ref}
onClick={handleClick}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', duration: 0.65 }}
css={{
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
zIndex: 50,
'.wrapper': {
display: 'flex',
position: 'relative',
},
}}
className={className}
>
{children}
</motion.div>
)
}
================================================
FILE: client/src/components/WidgetPalette.tsx
================================================
import imgEmergencyStop from '@art/emergency-stop_4x.png'
import imgTrash from '@art/trash_4x.png'
import { PropsOf, css } from '@emotion/react'
import { motion } from 'framer-motion'
import { ComicImage } from './ComicImage'
import { dialogStyles } from './SwooshyDialog'
import { MAX_WIDGET_COUNT } from './constants'
import { PaletteItem, WidgetData, stickerList, widgetList } from './widgets'
export const comicDropShadow = css({
filter: 'drop-shadow(2px 2px 0 rgba(0, 0, 0, 0.5))',
})
export function ToolButton({
disabled,
className,
'aria-label': ariaLabel,
onClick,
children,
...props
}: PropsOf<typeof motion.button>) {
return (
<motion.button
{...props}
onClick={onClick}
disabled={disabled}
whileTap={disabled ? undefined : { scale: 0.9 }}
css={{
padding: 0,
border: 'none',
background: 'none',
cursor: disabled ? 'default' : 'pointer',
zIndex: 20,
}}
className={className}
aria-label={ariaLabel}
>
{children}
</motion.button>
)
}
export default function WidgetPalette({
className,
widgetCount,
isHorizontal,
onAdd,
onTrash,
onEmergencyStop,
}: {
className?: string
widgetCount: number
isHorizontal: boolean
onAdd: (create: PaletteItem<WidgetData>['create']) => void
onTrash: () => void
onEmergencyStop: () => void
}) {
const canAddWidgets = widgetCount < MAX_WIDGET_COUNT
function renderList(list: Record<string, PaletteItem<WidgetData>>) {
return Object.entries(list).map(
([type, { preview: WidgetPreview, create }]) => (
<div
key={type}
onClick={() => onAdd(create)}
css={{
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
maxWidth: '100%',
maxHeight: '100%',
aspectRatio: 1,
cursor: 'pointer',
'@media (pointer: fine)': {
':hover': {
background: '#ccc',
},
},
}}
>
<WidgetPreview />
</div>
),
)
}
return (
<div
css={[
dialogStyles,
{
display: 'flex',
alignItems: 'center',
gap: 12,
zIndex: 70,
},
]}
style={
isHorizontal
? {
flexDirection: 'row',
paddingRight: 12,
}
: {
flexDirection: 'column',
paddingBottom: 12,
}
}
className={className}
>
<div
css={[
{
flex: 1,
display: 'flex',
flexDirection: isHorizontal ? 'row' : 'column',
maxWidth: '100%',
maxHeight: '100%',
overflow: 'auto',
scrollbarWidth: 'thin',
opacity: canAddWidgets ? 1 : 0.5,
pointerEvents: canAddWidgets ? 'all' : 'none',
},
isHorizontal && { height: '100%' },
]}
>
{renderList(widgetList)}
<div
css={{
borderBottom: '1px dashed black',
paddingTop: 10,
marginBottom: 10,
marginLeft: 4,
marginRight: 4,
}}
/>
{renderList(stickerList)}
</div>
{widgetCount > 0.75 * MAX_WIDGET_COUNT && (
<div
css={{
display: 'flex',
height: 26,
alignItems: 'center',
justifyContent: 'center',
borderTop: '2px solid black',
color: canAddWidgets ? 'black' : 'red',
}}
>
{widgetCount} / {MAX_WIDGET_COUNT}
</div>
)}
<ToolButton
onClick={onTrash}
aria-label="Delete selection"
css={{
height: imgTrash.height,
}}
>
<ComicImage img={imgTrash} />
</ToolButton>
<ToolButton
onClick={onEmergencyStop}
aria-label="Emergency stop"
css={{
height: imgEmergencyStop.height,
}}
>
<ComicImage img={imgEmergencyStop} />
</ToolButton>
</div>
)
}
================================================
FILE: client/src/components/constants.tsx
================================================
import imgBottomPit from '@art/bottom_pit_4x.png'
import imgBottomChute from '@art/chute-0v_4x.png'
import imgBottomTank from '@art/tank-blue_4x.png'
export const BALL_RADIUS = 8
export const INPUT_SPINNER_SIZE = 42
export const INPUT_SPINNER_SPEED = 2
export const INPUT_TEETH_COUNT = 8
export const INPUT_WIDTH = 2 * INPUT_SPINNER_SIZE + 5
export const MAX_WIDGET_COUNT = 100
export const TRIANGLE_BUMPER_SENSOR_OFFSET = 0.2
export const TRIANGLE_BUMPER_RADIUS_RATIO = 0.14159
export const TRIANGLE_BUMPER_SENSOR_FUDGE = 2.2
export const TRIANGLE_BUMPER_STRENGTH = 35
export const ROUND_BUMPER_STRENGTH = 35
export const BONK_ANIMATION_DELAY_MS = 200
export const TRIANGLE_BUMPER_CONTACT_DISTANCE = 0.1
export const ROAND_BUMPER_RADIUS_RATIO = 0.49
export const ROUND_BUMPER_FALLBACK_RATIO = 0.9
export const BOTTOM_PIT_WIDTH = imgBottomPit.width
export const BOTTOM_PIT_HEIGHT = imgBottomPit.height
export const BOTTOM_PIT_EDGE_WIDTH = 94
export const BOTTOM_MECHANISM_HEIGHT = 3000
export const BOTTOM_CHUTE_DROP = 50
export const BOTTOM_CHUTE_EXIT_OFFSET = 24
export const BOTTOM_CHUTE_HEIGHT = imgBottomChute.height
export const BOTTOM_TANK_WIDTH = imgBottomTank.width
export const BOTTOM_TANK_HEIGHT = imgBottomTank.height
export const BOTTOM_TANK_SPACING = 100
export const BOTTOM_TANK_TARGET_TOP = 8
================================================
FILE: client/src/components/moderation/BlueprintButton.tsx
================================================
import useIntersectionObserver from '@react-hook/intersection-observer'
import { truncate } from 'lodash'
import { useCallback, useRef } from 'react'
import { Puzzle } from '../../types'
import { default as ModMachineTileView } from './ModMachineTileView'
import { ServerBlueprint } from './modTypes'
export function BlueprintButton({
blueprintId,
blueprint,
puzzle,
tileWidth,
tileHeight,
isSelected,
isApproved,
onSelect,
}: {
blueprintId: string
blueprint: ServerBlueprint
puzzle: Puzzle
tileWidth: number
tileHeight: number
isSelected: boolean
isApproved: boolean
onSelect: (blueprintId: string) => void
}) {
const ref = useRef<HTMLButtonElement>(null)
const { isIntersecting } = useIntersectionObserver(ref)
const handleClick = useCallback(() => {
onSelect(blueprintId)
}, [blueprintId, onSelect])
return (
<button
ref={ref}
css={{
position: 'relative',
background: 'none',
border: 'none',
outline: `3px solid ${isSelected ? 'blue' : isApproved ? 'green' : 'black'}`,
borderRadius: 4,
width: 150,
height: 150,
padding: 0,
}}
onClick={handleClick}
>
<div
css={{
position: 'absolute',
right: 2,
bottom: 2,
color: isApproved ? 'green' : isSelected ? 'blue' : 'gray',
fontWeight: 'bold',
background: 'white',
textAlign: 'right',
zIndex: 10,
}}
>
{isApproved
? 'current approved'
: isSelected
? 'viewing'
: truncate(blueprint.title, { length: 42 })}
</div>
{isIntersecting && (
<ModMachineTileView
puzzle={puzzle}
blueprint={blueprint}
width={150}
height={150}
tileWidth={tileWidth}
tileHeight={tileHeight}
/>
)}
</button>
)
}
================================================
FILE: client/src/components/moderation/ContextGridForMachineAt.tsx
================================================
import { css } from '@emotion/react'
import { gridDimensions, iterTiles, tileKey } from '../../lib/tiles'
import { inBounds } from '../../lib/utils'
import { Puzzle } from '../../types'
import LoadingSpinner from '../LoadingSpinner'
import ModMachineTileView from './ModMachineTileView'
import { ModTileInputOutputView } from './ModTileInputOutputView'
import { ModLocation, ModMachine, ServerBlueprint } from './modTypes'
import { useContextBlueprints, useContextPuzzles } from './moderatorClient'
const TILE_WIDTH = 100
const tileWrapperStyles = css({
outline: '1px solid rgba(0, 0, 0, .35)',
userSelect: 'none',
})
const centerTextStyles = css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
})
function OOBTile() {
return (
<div
css={[
{ color: 'gainsboro', backgroundColor: 'slategrey' },
tileWrapperStyles,
centerTextStyles,
]}
>
OUT OF
<br />
BOUNDS
</div>
)
}
function EmptyTile({
puzzle,
toModCount,
onClick,
}: {
puzzle: Puzzle
toModCount: number | undefined
onClick?: () => void
}) {
return (
<div
onClick={onClick}
css={[
{
position: 'relative',
backgroundColor: 'gainsboro',
overflow: 'hidden',
},
tileWrapperStyles,
centerTextStyles,
]}
>
{toModCount}
<ModTileInputOutputView puzzle={puzzle} />
</div>
)
}
export default function ContextGridForMachineAt({
modMachine,
xt: viewXt,
yt: viewYt,
selectedBlueprint,
tileOutset = 2,
onSelectLocation,
}: {
modMachine: ModMachine
selectedBlueprint: ServerBlueprint | undefined
tileOutset?: number
onSelectLocation: (xt: number, yt: number) => void
} & ModLocation) {
const sideSize: number = tileOutset * 2 + 1
const [tilesX, tilesY] = gridDimensions(modMachine.grid)
const contextLocs: Array<
| (ModLocation & { oob: false; toModCount: number | undefined })
| {
xt: number
yt: number
oob: true
}
> = Array.from(
iterTiles(
viewXt - tileOutset,
viewYt - tileOutset,
viewXt + tileOutset,
viewYt + tileOutset,
),
([xt, yt]) =>
inBounds(xt, yt, [0, 0, tilesX - 1, tilesY - 1])
? {
xt,
yt,
oob: false,
puzzle: modMachine.grid[yt][xt].puzzle,
blueprint: modMachine.grid[yt][xt].blueprint,
toModCount: modMachine.grid[yt][xt].to_mod,
}
: { xt, yt, oob: true },
)
const puzzles = useContextPuzzles(
contextLocs.map((l) => (l.oob ? undefined : l.puzzle)),
)
const blueprints = useContextBlueprints(
contextLocs.map((l) => (l.oob ? undefined : l.blueprint)),
)
const contextTiles = contextLocs.map((loc, idx) => {
const { xt, yt, oob } = loc
const key = tileKey(xt, yt)
const blueprint = blueprints[idx].data?.blueprint
const puzzle = puzzles[idx].data
if (puzzles[idx].isLoading || blueprints[idx].isLoading) {
return <LoadingSpinner key={key} />
}
// This would be annoying to useCallback so I'm skipping it
const handleClick = () => {
onSelectLocation(xt, yt)
}
if (oob) {
return <OOBTile key={key} />
} else if (!puzzle) {
return <LoadingSpinner key={key} />
} else if (!loc.blueprint) {
return (
<EmptyTile
key={key}
puzzle={puzzle}
toModCount={loc.toModCount}
onClick={handleClick}
/>
)
} else if (xt === viewXt && yt === viewYt) {
return (
<div key={key} onClick={handleClick} css={tileWrapperStyles}>
{selectedBlueprint ? (
<ModMachineTileView
puzzle={puzzle}
blueprint={blueprint}
width={TILE_WIDTH}
height={TILE_WIDTH}
tileWidth={modMachine.tile_size.x}
tileHeight={modMachine.tile_size.y}
/>
) : null}
</div>
)
} else {
return (
<div key={key} css={tileWrapperStyles} onClick={handleClick}>
<ModMachineTileView
puzzle={puzzle}
blueprint={blueprint}
width={TILE_WIDTH}
height={TILE_WIDTH}
tileWidth={modMachine.tile_size.x}
tileHeight={modMachine.tile_size.y}
/>
</div>
)
}
})
return (
<div
css={{
display: 'grid',
gridTemplateColumns: `repeat(${sideSize}, ${TILE_WIDTH}px)`,
gridTemplateRows: `repeat(${sideSize}, ${TILE_WIDTH}px)`,
gridColumnGap: '0px',
gridRowGap: '0px',
gridAutoFlow: 'row',
aspectRatio: '1',
justifyItems: 'center',
alignItems: 'center',
width: sideSize * TILE_WIDTH,
}}
>
{contextTiles}
</div>
)
}
================================================
FILE: client/src/components/moderation/LiveMachinePreview.tsx
================================================
import imgCheck from '@art/check-circle_4x.png'
import imgWrong from '@art/wrong-circle_4x.png'
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Bounds, Puzzle } from '../../types'
import { ComicImage } from '../ComicImage'
import LoadingSpinner from '../LoadingSpinner'
import {
MachineContextProvider,
MachineContextProviderRef,
} from '../MachineContext'
import {
MachineTileContextProvider,
MachineTileContextProviderRef,
} from '../MachineTileContext'
import { PhysicsContextProvider } from '../PhysicsContext'
import { MAX_WIDGET_COUNT } from '../constants'
import { CircleGauge } from '../widgets/CircleGauge'
import ModMachineTileView from './ModMachineTileView'
import { ModLocation, ModMachine, ServerBlueprint } from './modTypes'
import {
useApproveBlueprint,
useBurnBlueprint,
useReissuePuzzle,
} from './moderatorClient'
const MIN_SECONDS_TO_MOD = 30
function useCountdown(initialSeconds: number) {
const [seconds, setSeconds] = useState(initialSeconds)
useEffect(() => {
const startTime = performance.now()
function dec() {
const now = performance.now()
const remainingSeconds = initialSeconds - (now - startTime) / 1000
setSeconds(remainingSeconds)
if (remainingSeconds <= 0) {
clearInterval(interval)
}
}
const interval = setInterval(dec, 150)
return () => {
clearInterval(interval)
}
}, [initialSeconds])
return seconds
}
function ValidationLine({
isValid,
children,
}: {
isValid: boolean
children: ReactNode
}) {
return (
<div
css={{
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<ComicImage img={isValid ? imgCheck : imgWrong} />
<span>{children}</span>
</div>
)
}
export function LiveMachinePreview({
loc,
modMachine,
puzzle,
blueprintId,
blueprint,
onNextBlueprint,
}: {
loc: ModLocation
modMachine: ModMachine
puzzle: Puzzle
blueprintId: string | undefined
blueprint: ServerBlueprint | undefined
onNextBlueprint: () => void
}) {
const machineRef = useRef<MachineContextProviderRef>(null)
const machineTileRef = useRef<MachineTileContextProviderRef>(null)
const approveBlueprint = useApproveBlueprint()
const burnBlueprint = useBurnBlueprint()
const reissuePuzzle = useReissuePuzzle()
const actionCountdown = useCountdown(MIN_SECONDS_TO_MOD)
const [isValidOutputs, setValidOutputs] = useState(false)
const widgetCount = blueprint ? Object.keys(blueprint.widgets).length : 0
const isWidgetCountValid = widgetCount <= MAX_WIDGET_COUNT
const canApprove =
actionCountdown <= 0 && isValidOutputs && isWidgetCountValid
const handleOutputValidate = useCallback((isValid: boolean) => {
setValidOutputs(isValid)
}, [])
let modStatus = ''
const isError =
approveBlueprint.isError || burnBlueprint.isError || reissuePuzzle.isError
if (approveBlueprint.isPending || burnBlueprint.isPending) {
modStatus = 'Working...'
} else if (isError) {
modStatus = 'Error :('
} else if (approveBlueprint.isSuccess) {
modStatus = 'Approved!'
} else if (burnBlueprint.isSuccess) {
modStatus = 'Burnt!'
} else if (reissuePuzzle.isSuccess) {
modStatus = 'Reissued!'
}
const handleApprove = useCallback(() => {
const { current: machineTile } = machineTileRef
if (!machineTile || !blueprintId) {
return
}
const snapshot = machineTile.snapshot()
approveBlueprint.mutate({
xt: loc.xt,
yt: loc.yt,
blueprintId,
snapshot,
})
}, [approveBlueprint, blueprintId, loc.xt, loc.yt])
const handleBurn = useCallback(() => {
if (!blueprintId) {
return
}
void burnBlueprint.mutate({ puzzleId: loc.puzzle, blueprintId })
onNextBlueprint()
}, [blueprintId, burnBlueprint, loc.puzzle, onNextBlueprint])
const handleReissue = useCallback(() => {
if (!blueprint) {
return
}
void reissuePuzzle.mutate({ puzzleId: blueprint.puzzle })
}, [blueprint, reissuePuzzle])
const tileBounds: Bounds = useMemo(
() => [0, 0, modMachine.tile_size.x, modMachine.tile_size.y],
[modMachine.tile_size.x, modMachine.tile_size.y],
)
return (
<div
css={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 16,
}}
>
<PhysicsContextProvider>
<MachineContextProvider
ref={machineRef}
msPerBall={modMachine.ms_per_ball}
initialSimulationBounds={tileBounds}
initialViewBounds={tileBounds}
>
<MachineTileContextProvider ref={machineTileRef} bounds={tileBounds}>
{!puzzle ? (
<LoadingSpinner />
) : (
<ModMachineTileView
puzzle={puzzle}
blueprint={blueprint}
width={500}
height={500}
tileWidth={modMachine.tile_size.x}
tileHeight={modMachine.tile_size.y}
onValidate={handleOutputValidate}
/>
)}
</MachineTileContextProvider>
</MachineContextProvider>
</PhysicsContextProvider>
{blueprint ? (
<>
<div css={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<h3 css={{ margin: 0 }}>"{blueprint.title}"</h3>
<ValidationLine isValid={isValidOutputs}>
{isValidOutputs
? 'All outputs are valid'
: 'Not all outputs are valid'}
</ValidationLine>
<ValidationLine isValid={isWidgetCountValid}>
{widgetCount} / {MAX_WIDGET_COUNT} widgets used
</ValidationLine>
</div>
<div
css={{
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
}}
>
<CircleGauge
value={actionCountdown / 30}
lineWidth={0.5}
css={{
width: 20,
stroke: 'gray',
}}
/>
<button onClick={handleApprove} disabled={!canApprove}>
Approve
</button>
<button onClick={handleBurn}>Burn</button>
<button onClick={handleReissue}>Reissue Puzzle</button>
{modStatus && (
<span style={{ color: isError ? 'red' : 'black' }}>
{modStatus}
</span>
)}
</div>
</>
) : null}
</div>
)
}
================================================
FILE: client/src/components/moderation/ModMachineTileView.tsx
================================================
import { Puzzle, WidgetCollection } from '../../types'
import { Widgets } from '../widgets'
import { Balls } from '../widgets/Balls'
import MachineFrame from '../widgets/MachineFrame'
import { ModTileInputOutputView } from './ModTileInputOutputView'
import { ServerBlueprint } from './modTypes'
export default function ModMachineTileView({
puzzle,
blueprint,
width,
height,
tileWidth,
tileHeight,
onValidate,
className,
}: {
puzzle: Puzzle
blueprint?: ServerBlueprint
width: number
height: number
tileWidth: number
tileHeight: number
onValidate?: (isValid: boolean) => void
className?: string
}) {
return (
<div css={{ position: 'relative', width, height, overflow: 'hidden' }}>
<div
css={{
position: 'absolute',
left: 0,
top: 0,
width: tileWidth,
height: tileHeight,
transform: `scale(${width / tileWidth})`,
transformOrigin: 'left top',
aspectRatio: '1',
}}
className={className}
>
{blueprint ? (
<Widgets widgets={blueprint.widgets as WidgetCollection} />
) : null}
{puzzle ? (
<MachineFrame
key={blueprint?.puzzle}
inputs={puzzle.inputs}
outputs={puzzle.outputs}
onValidate={onValidate}
validateOutputs={onValidate != null}
spawnBallsTop
spawnBallsLeft
spawnBallsRight
/>
) : null}
<Balls />
</div>
<ModTileInputOutputView puzzle={puzzle} />
</div>
)
}
================================================
FILE: client/src/components/moderation/ModTileInputOutputView.tsx
================================================
import { css } from '@emotion/react'
import { percent } from '../../lib/utils'
import { Puzzle, PuzzlePosition } from '../../types'
const posClassNames = ['blue', 'red', 'green', 'yellow']
const posStyles = css({
width: 6,
height: 6,
zIndex: 20,
'&.input': {
borderRadius: '100%',
},
'&.blue': {
background: 'blue',
},
'&.red': {
background: 'red',
},
'&.green': {
background: 'green',
},
'&.yellow': {
background: 'yellow',
},
})
export function TinyInputOutput({
x,
y,
balls,
isInput,
style,
}: {
isInput?: boolean
style?: React.CSSProperties
} & PuzzlePosition) {
return (
<div
style={{
...style,
position: 'absolute',
left: `${percent(x)}`,
top: `${percent(y)}`,
transform: 'translate(-50%, -50%)',
}}
>
{balls.map(({ type }, idx) => (
<div
key={idx}
css={posStyles}
className={`${posClassNames[type - 1]} ${isInput && 'input'}`}
></div>
))}
</div>
)
}
export function ModTileInputOutputView({ puzzle }: { puzzle: Puzzle }) {
return (
<>
{puzzle.inputs.map((input, idx) => (
<TinyInputOutput key={idx} {...input} isInput />
))}
{puzzle.outputs.map((input, idx) => (
<TinyInputOutput key={idx} {...input} />
))}
</>
)
}
================================================
FILE: client/src/components/moderation/Moderator.tsx
================================================
import { Global } from '@emotion/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { clamp } from 'lodash'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { components } from '../../generated/api-spec'
import { gridDimensions } from '../../lib/tiles'
import LoadingSpinner from '../LoadingSpinner'
import { paramToNumber, useLocationHashParams } from '../useLocationHashParams'
import { BlueprintButton } from './BlueprintButton'
import ContextGridForMachineAt from './ContextGridForMachineAt'
import { LiveMachinePreview } from './LiveMachinePreview'
import SelectTileForm from './SelectTileForm'
import { ModLocation } from './modTypes'
import { getEmptyTile, locFromPosition, sortCandidateMap } from './modUtils'
import {
puzzleQueryOptions,
useBlueprint,
useCandidateBlueprints,
useModeratorMachine,
} from './moderatorClient'
function useModHashParams(): {
hashLoc: ModLocation | null
setHashLoc: (loc: ModLocation) => void
} {
const { locationHashParams, setLocationHashParams } = useLocationHashParams()
const hashLoc = useMemo(() => {
const blueprint = locationHashParams.get('blueprint') ?? undefined
const puzzle = locationHashParams.get('puzzle')
const xt = paramToNumber(locationHashParams.get('xt'))
const yt = paramToNumber(locationHashParams.get('yt'))
if (!puzzle || xt == null || yt == null) {
return null
}
return {
blueprint,
puzzle,
xt,
yt,
}
}, [locationHashParams])
const setHashLoc = useCallback(
(loc: ModLocation) => {
setLocationHashParams({
blueprint: loc.blueprint,
puzzle: loc.puzzle,
xt: String(loc.xt),
yt: String(loc.yt),
})
},
[setLocationHashParams],
)
return { hashLoc, setHashLoc }
}
function BlueprintModerator({
modMachine,
}: {
modMachine: components['schemas']['VersionedMachine ModData']
}) {
const { hashLoc, setHashLoc } = useModHashParams()
const [loc, setLoc] = useState(() =>
hashLoc
? locFromPosition(modMachine.grid, hashLoc.xt, hashLoc.yt)
: getEmptyTile(modMachine),
)
const queryClient = useQueryClient()
const { data: locFolio } = useBlueprint(loc.blueprint)
const { data: candidateBlueprints } = useCandidateBlueprints({
puzzleId: loc.puzzle,
})
const [selectedBlueprintId, setSelectedBlueprintId] = useState<
string | undefined
>(hashLoc?.blueprint)
useEffect(() => {
setHashLoc({
...loc,
blueprint: selectedBlueprintId,
})
}, [loc, selectedBlueprintId, setHashLoc])
useEffect(() => {
if (!hashLoc) {
return
}
setLoc(locFromPosition(modMachine.grid, hashLoc.xt, hashLoc.yt))
setSelectedBlueprintId(hashLoc.blueprint)
}, [hashLoc, modMachine.grid])
const sortedCandidateBlueprints = useMemo(
() =>
candidateBlueprints
? sortCandidateMap(candidateBlueprints, loc.blueprint)
: null,
[candidateBlueprints, loc.blueprint],
)
const selectedBlueprint =
selectedBlueprintId === loc.blueprint
? locFolio?.blueprint
: selectedBlueprintId
? candidateBlueprints?.get(selectedBlueprintId)
: undefined
const { data: locPuzzle } = useQuery(puzzleQueryOptions(loc.puzzle))
const handleGotoLoc = useCallback(
(rawXt: number, rawYt: number) => {
const [tilesX, tilesY] = gridDimensions(modMachine.grid)
const xt = clamp(rawXt, 0, tilesX - 1)
const yt = clamp(rawYt, 0, tilesY - 1)
setLoc({
xt,
yt,
...modMachine.grid[yt][xt],
})
void queryClient.invalidateQueries({
queryKey: ['moderator', 'machine'],
})
},
[modMachine.grid, queryClient],
)
const handleNextEmpty = useCallback(() => {
void queryClient
.invalidateQueries({
queryKey: ['moderator', 'machine'],
})
.then(() => {
setLoc(getEmptyTile(modMachine))
})
}, [modMachine, queryClient])
const handleNextBlueprint = useCallback(() => {
if (!sortedCandidateBlueprints || !selectedBlueprintId) {
return
}
const currentIdx = sortedCandidateBlueprints.findIndex(
([id]) => id === selectedBlueprintId,
)
if (currentIdx === -1) {
return
}
const nextItem = sortedCandidateBlueprints[currentIdx + 1]
if (nextItem) {
setSelectedBlueprintId(nextItem[0])
}
}, [selectedBlueprintId, sortedCandidateBlueprints])
// If the selected blueprint id isn't valid, pick a suitable one
useEffect(() => {
if (
selectedBlueprintId != null &&
(selectedBlueprintId === loc.blueprint ||
!candidateBlueprints ||
candidateBlueprints.has(selectedBlueprintId))
) {
return
}
if (loc.blueprint != null) {
setSelectedBlueprintId(loc.blueprint)
return
}
if (candidateBlueprints) {
const firstId = candidateBlueprints.keys().next()
setSelectedBlueprintId(firstId.done === false ? firstId.value : undefined)
}
}, [selectedBlueprintId, candidateBlueprints, loc.blueprint])
return (
<div>
<Global
styles={{
body: {
fontFamily: 'xkcd-Regular-v3',
},
'h1, h2, h3': {
fontWeight: 'normal',
},
}}
/>
<h1>Incredible Modview</h1>
<div
css={{
display: 'flex',
alignItems: 'top',
justifyContent: 'center',
minHeight: 700,
'& > div': {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: 1,
gap: 16,
},
h2: {
margin: 0,
},
}}
>
<div>
<h2>Context Window</h2>
<ContextGridForMachineAt
modMachine={modMachine}
selectedBlueprint={selectedBlueprint}
onSelectLocation={handleGotoLoc}
{...loc}
/>
<SelectTileForm
xt={loc.xt}
yt={loc.yt}
onGotoLoc={handleGotoLoc}
onNextEmpty={handleNextEmpty}
/>
</div>
<div>
<h2>Candidate Machine</h2>
{locPuzzle ? (
<LiveMachinePreview
key={selectedBlueprintId}
loc={loc}
modMachine={modMachine}
puzzle={locPuzzle}
blueprintId={selectedBlueprintId}
blueprint={selectedBlueprint}
onNextBlueprint={handleNextBlueprint}
/>
) : (
<LoadingSpinner />
)}
</div>
</div>
<h2 css={{ marginTop: 48 }}>Candidate Machine Options</h2>
{
<div
css={{
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'center',
margin: 16,
gap: 8,
}}
>
{loc.blueprint && locFolio && (
<BlueprintButton
key={loc.blueprint}
blueprintId={loc.blueprint}
blueprint={locFolio.blueprint}
puzzle={locFolio.puzzle}
tileWidth={modMachine.tile_size.x}
tileHeight={modMachine.tile_size.y}
isSelected={loc.blueprint === selectedBlueprintId}
isApproved={true}
onSelect={setSelectedBlueprintId}
/>
)}
{sortedCandidateBlueprints && locPuzzle
? sortedCandidateBlueprints.map(([blueprintId, blueprint]) => (
<BlueprintButton
key={blueprintId}
blueprintId={blueprintId}
blueprint={blueprint}
puzzle={locPuzzle}
tileWidth={modMachine.tile_size.x}
tileHeight={modMachine.tile_size.y}
isSelected={blueprintId === selectedBlueprintId}
isApproved={blueprintId === loc.blueprint}
onSelect={setSelectedBlueprintId}
/>
))
: null}
</div>
}
</div>
)
}
export default function Moderator() {
const { data: modMachine } = useModeratorMachine()
if (!modMachine) {
return <LoadingSpinner css={{ height: '100vh' }} />
}
return <BlueprintModerator modMachine={modMachine} />
}
================================================
FILE: client/src/components/moderation/SelectTileForm.tsx
================================================
import React, { useCallback } from 'react'
export default function SelectTileForm({
xt,
yt,
onGotoLoc,
onNextEmpty,
}: {
xt: number
yt: number
onGotoLoc: (xt: number, yt: number) => void
onNextEmpty: () => void
}) {
const handleChangeX = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
onGotoLoc(Number(ev.target.value), yt)
},
[onGotoLoc, yt],
)
const handleChangeY = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
onGotoLoc(xt, Number(ev.target.value))
},
[onGotoLoc, xt],
)
return (
<div
css={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}
>
<form css={{ display: 'flex', gap: 8, input: { width: '5ch' } }}>
<label htmlFor="gotoX">X:</label>
<input
type="number"
value={xt}
onChange={handleChangeX}
id="gotoX"
></input>
<label htmlFor="gotoY">Y:</label>
<input
type="number"
value={yt}
onChange={handleChangeY}
id="gotoY"
></input>
</form>
<button onClick={onNextEmpty}>Go to random tile</button>
</div>
)
}
================================================
FILE: client/src/components/moderation/interestingWeights.ts
================================================
import { WidgetType } from '../widgets'
export const interestingWeights: Record<WidgetType, number> = {
brick: 1,
anvil: 3,
attractor: 8,
repulsor: 9,
board: 5,
fan: 10,
hammer: 3,
sword: 6,
leftbumper: 5,
rightbumper: 5,
roundbumper: 5,
hook: 7,
lefthook: 8,
cushion: 5,
spokedwheel: 6,
prism: 10,
sticker: 5,
ballstand: 10,
cup: 5,
catswat: 10,
}
================================================
FILE: client/src/components/moderation/modTypes.d.ts
================================================
import { components } from '../../generated/api-spec'
export type ModLocation = {
puzzle: string
blueprint?: string
xt: number
yt: number
}
export type ServerBlueprint = components['schemas']['Blueprint']
export type CandidateMap = Map<string, ServerBlueprint>
export type ModMachine = components['schemas']['VersionedMachine ModData']
================================================
FILE: client/src/components/moderation/modUtils.ts
================================================
import { random, sample, sortBy } from 'lodash'
import { gridDimensions, iterTiles } from '../../lib/tiles'
import { intersectBounds } from '../../lib/utils'
import { WidgetCollection } from '../../types'
import { interestingWeights } from './interestingWeights'
import {
CandidateMap,
ModLocation,
ModMachine,
ServerBlueprint,
} from './modTypes'
export function getRandEmptyNearTile(
modMachine: ModMachine,
baseXt: number,
baseYt: number,
contextWindow: number,
): ModLocation | undefined {
const [tilesX, tilesY] = gridDimensions(modMachine.grid)
const nearbyEmpties: ModLocation[] = []
for (const [xt, yt] of iterTiles(
...intersectBounds(
[baseXt, baseYt, baseXt + contextWindow, baseYt + contextWindow],
[0, 0, tilesX - 1, tilesY - 1],
),
)) {
const tile = modMachine.grid[yt][xt]
if (!tile.blueprint && tile.to_mod) {
nearbyEmpties.push({ ...modMachine.grid[yt][xt], xt, yt })
}
}
if (nearbyEmpties.length > 0) {
return sample(nearbyEmpties)
}
return undefined
}
export function getEmptyTile(modMachine: ModMachine): ModLocation {
const [tilesX, tilesY] = gridDimensions(modMachine.grid)
const emptyTiles: ModLocation[] = []
for (const [xt, yt] of iterTiles(0, 0, tilesX - 1, tilesY - 1)) {
const { blueprint } = modMachine.grid[yt][xt]
if (blueprint) {
const nearbyEmpty = getRandEmptyNearTile(modMachine, xt, yt, 5)
if (nearbyEmpty) {
return nearbyEmpty
}
} else {
emptyTiles.push({ ...modMachine.grid[yt][xt], xt, yt })
}
}
if (emptyTiles.length > 0) {
return emptyTiles[random(emptyTiles.length)]
}
const xt = random(tilesX)
const yt = random(tilesY)
return { ...modMachine.grid[yt][xt], xt, yt }
}
export function calculateInterest(b: ServerBlueprint): number {
const widgets = b.widgets as WidgetCollection
let interestingness: number = 0
for (const [_, widget] of Object.entries(widgets)) {
interestingness += interestingWeights[widget.type]
}
return interestingness
}
export function sortCandidateMap(
candidates: CandidateMap,
currentBlueprintId: string | undefined,
): Array<[string, ServerBlueprint]> {
return sortBy([...candidates.entries()], ([blueprintId, blueprint]) =>
blueprintId === currentBlueprintId
? -Infinity
: -calculateInterest(blueprint),
)
}
export function locFromPosition(
grid: ModMachine['grid'],
xt: number,
yt: number,
) {
return {
...grid[yt][xt],
xt: xt,
yt: yt,
}
}
================================================
FILE: client/src/components/moderation/moderatorClient.ts
================================================
import {
queryOptions,
useMutation,
useQueries,
useQuery,
} from '@tanstack/react-query'
import { apiClient, queryClient } from '../../api'
import { MachineSnapshot } from '../../lib/snapshot'
import { blueprintQueryOptions } from '../useMetaMachineClient'
import { CandidateMap } from './modTypes'
export function puzzleQueryOptions(puzzleId: string | undefined) {
return queryOptions({
enabled: puzzleId != null,
queryKey: ['moderator', 'puzzle', puzzleId],
queryFn: async ({ signal }) => {
const { data } = await apiClient.GET('/moderate/puzzle/{puzzleid}', {
params: {
path: {
puzzleid: puzzleId!,
},
},
credentials: 'include',
signal,
})
return data
},
staleTime: Infinity,
})
}
export function useModeratorMachine() {
return useQuery({
queryKey: ['moderator', 'machine'],
queryFn: async ({ signal }) => {
const { data } = await apiClient.GET('/moderate/machine/current', {
credentials: 'include',
signal,
})
return data
},
staleTime: 30 * 1000,
})
}
export function useCandidateBlueprints({ puzzleId }: { puzzleId: string }) {
return useQuery<CandidateMap>({
queryKey: ['moderator', 'blueprints', puzzleId],
queryFn: async ({ signal }) => {
const { data } = await apiClient.GET(
'/moderate/puzzle/{puzzleid}/blueprint',
{
params: {
path: {
puzzleid: puzzleId,
},
},
credentials: 'include',
signal,
},
)
return new Map(data)
},
})
}
export function useBlueprint(blueprintId: string | undefined) {
return useQuery(blueprintQueryOptions(blueprintId))
}
export function useContextBlueprints(blueprintIds: Array<string | undefined>) {
return useQueries({
queries: blueprintIds.map((id) => blueprintQueryOptions(id)),
})
}
export function useContextPuzzles(puzzleIds: Array<string | undefined>) {
return useQueries({
queries: puzzleIds.map(puzzleQueryOptions),
})
}
export function useApproveBlueprint() {
return useMutation({
mutationFn: ({
xt,
yt,
blueprintId,
snapshot,
}: {
xt: number
yt: number
blueprintId: string
snapshot: MachineSnapshot
}) => {
return apiClient.POST('/moderate/build/{X}/{Y}', {
params: {
path: {
X: xt,
Y: yt,
},
},
body: {
blueprint: blueprintId,
snapshot: snapshot as unknown as Record<string, unknown>,
},
credentials: 'include',
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['moderator', 'machine'] })
},
})
}
export function useBurnBlueprint() {
return useMutation({
mutationFn: ({
blueprintId,
}: {
puzzleId: string
blueprintId: string
}) => {
return apiClient.POST('/moderate/burn/{blueprintid}', {
params: {
path: {
blueprintid: blueprintId,
},
},
credentials: 'include',
})
},
onSuccess: (_data, { puzzleId }) => {
void queryClient.invalidateQueries({
queryKey: ['moderator', 'blueprints', puzzleId],
})
},
})
}
export function useReissuePuzzle() {
return useMutation({
mutationFn: ({ puzzleId }: { puzzleId: string }) => {
return apiClient.POST('/moderate/puzzle/{puzzleid}/reissue', {
params: {
path: {
puzzleid: puzzleId,
},
},
credentials: 'include',
})
},
})
}
================================================
FILE: client/src/components/positionStyles.ts
================================================
import type { RigidBody } from '@dimforge/rapier2d'
import {
MutableRefObject,
RefObject,
useCallback,
useEffect,
useRef,
} from 'react'
import { coords } from '../lib/coords'
import { inBoundsObject, px } from '../lib/utils'
import { useMachine } from './MachineContext'
import { useLoopHandler } from './PhysicsContext'
export function getPositionStyles(x: number, y: number, angle: number = 0) {
return {
position: 'absolute' as const,
top: 0,
left: 0,
transform: `translate(${px(x)}, ${px(y)}) translate(-50%, -50%) rotate(${angle}rad)`,
}
}
export function usePositionedBodyRef<T extends HTMLElement>(
bodyRef: RefObject<RigidBody>,
{
width,
height,
initialX,
initialY,
initialAngle,
xBasis = 0,
yBasis = 0,
}: {
width: number
height: number
initialX: number
initialY: number
initialAngle?: number
xBasis?: number
yBasis?: number
},
): MutableRefObject<T | null> {
const elRef = useRef<T>(null)
const { viewBoundsRef } = useMachine()
const wasVisibleRef = useRef(true)
const updateStyle = useCallback(() => {
const { current: el } = elRef
const { current: body } = bodyRef
if (!el) {
return
}
const [bodyX, bodyY] = body ? coords.fromBody.vector(body) : []
const x = bodyX ?? initialX
const y = bodyY ?? initialY
if (x == null || y == null) {
el.style.display = 'none'
wasVisibleRef.current = false
return
}
const isVisible = inBoundsObject(x, y, width, height, viewBoundsRef.current)
if (isVisible != wasVisibleRef.current) {
el.style.display = isVisible ? 'block' : 'none'
}
if (isVisible) {
Object.assign(
el.style,
getPositionStyles(
x - xBasis,
y - yBasis,
body ? coords.fromBody.angle(body) : initialAngle,
),
)
}
wasVisibleRef.current = isVisible
}, [
bodyRef,
initialX,
initialY,
initialAngle,
width,
height,
viewBoundsRef,
xBasis,
yBasis,
])
useEffect(updateStyle)
useLoopHandler(updateStyle, [updateStyle])
return elRef
}
================================================
FILE: client/src/components/useLocationHashParams.tsx
================================================
import { useCallback, useEffect, useMemo, useState } from 'react'
export function paramToNumber(text: string | null | undefined) {
if (text == null) {
return undefined
}
const val = Number(text)
if (isNaN(val)) {
return undefined
}
return val
}
export function useLocationHashParams() {
const [, triggerUpdate] = useState(0)
useEffect(() => {
function handleHashChange() {
triggerUpdate((x) => x + 1)
}
window.addEventListener('hashchange', handleHashChange)
return () => {
window.removeEventListener('hashchange', handleHashChange)
}
}, [])
const params = useMemo(
() => new URLSearchParams(location.hash.slice(1)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[location.hash],
)
const setLocationHashParams = useCallback(
(data: Record<string, string | null | undefined>) => {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(data)) {
if (v == null) {
continue
}
params.set(k, v)
}
const newHash = new URLSearchParams(params).toString()
const currentURLWithoutHash = window.location.href.split('#')[0]
window.location.replace(`${currentURLWithoutHash}#${newHash}`)
},
[],
)
return {
locationHashParams: params,
setLocationHashParams,
}
}
================================================
FILE: client/src/components/useMetaMachineClient.ts
================================================
import {
queryOptions,
useMutation,
useQueries,
useQuery,
} from '@tanstack/react-query'
import { dropRightWhile, isNull } from 'lodash'
import { useCallback, useMemo } from 'react'
import invariant from 'tiny-invariant'
import { apiClient, queryClient } from '../api'
import { MachineSnapshot } from '../lib/snapshot'
import {
gridDimensions,
gridViewBounds,
iterTiles,
tileKey,
} from '../lib/tiles'
import { Bounds, Puzzle, PuzzleOrder, WidgetCollection } from '../types'
import { WidgetData } from './widgets'
export interface TileData {
blueprintId: string
title: string
puzzle: Puzzle
widgets: WidgetCollection
snapshot?: MachineSnapshot
}
export type GetMachineFunction = (
xt: number,
yt: number,
) => TileData | undefined
export interface MetaMachineInfo {
version: number
tileWidth: number
tileHeight: number
tilesX: number
tilesY: number
getMachine: GetMachineFunction
hasBlueprint: (xt: number, yt: number) => boolean
msPerBall: number
}
export interface MetaMachineClient {
isLoading: boolean
allTilesLoaded: boolean
metaMachine: MetaMachineInfo | null
}
export interface SavedBlueprintLocation {
blueprintId: string
xt: number
yt: number
}
export type SavedMachine = SavedBlueprintLocation & TileData
export function blueprintQueryOptions(blueprintid: string | undefined) {
return queryOptions({
enabled: blueprintid != null,
queryKey: ['folio', blueprintid],
queryFn: async ({ signal }) => {
invariant(blueprintid, 'blueprintid must be non-null')
const { data } = await apiClient.GET('/folio/{blueprintid}', {
params: {
path: {
blueprintid,
},
},
signal,
})
return data
},
staleTime: Infinity,
})
}
export function useMetaMachineClient({
viewBounds,
lastSubmission,
version: fetchVersion,
fetchOutset = 2000,
}: {
viewBounds: Bounds
lastSubmission?: SavedMachine | undefined
version?: number
fetchOutset?: number
}): MetaMachineClient {
const { data: machineData, isLoading } = useQuery({
queryKey: ['machine', fetchVersion ?? 'current'],
queryFn: async ({ signal }) => {
if (fetchVersion != null) {
const { data } = await apiClient.GET('/machine/{version}', {
params: { path: { version: fetchVersion } },
signal,
})
return data
} else {
const { data } = await apiClient.GET('/machine/current', { signal })
return data
}
},
})
const tileWidth = machineData?.tile_size.x ?? 0
const tileHeight = machineData?.tile_size.y ?? 0
const msPerBall = machineData?.ms_per_ball ?? Infinity
const version = machineData?.version ?? 0
const trimmedGrid = machineData?.grid
? dropRightWhile(machineData.grid, (row, idx) => {
if (idx === 0) {
return false
}
if (lastSubmission && idx <= lastSubmission.yt) {
return false
}
return row.every(isNull)
})
: []
const [tilesX, tilesY] = machineData ? gridDimensions(trimmedGrid) : [0, 0]
const tileBounds: Bounds = machineData
? gridViewBounds(
viewBounds,
tilesX,
tilesY,
tileWidth,
tileHeight,
fetchOutset,
)
: [0, 0, 0, 0]
const tiles = [...iterTiles(...tileBounds)]
const { sparseTileData, allTilesLoaded } = useQueries({
queries: machineData
? tiles.map(([xt, yt]) => blueprintQueryOptions(trimmedGrid[yt][xt]))
: [],
combine: (results) => {
const sparseTileData: Record<string, TileData> = {}
let allTilesLoaded = results.length > 0
for (let i = 0; i < results.length; i++) {
allTilesLoaded &&= !results[i].isLoading
const data = results[i].data
const [xt, yt] = tiles[i]
if (!data) {
continue
}
sparseTileData[tileKey(xt, yt)] = {
blueprintId: trimmedGrid[yt][xt],
title: data.blueprint.title,
puzzle: data.puzzle,
widgets: data.blueprint.widgets as Record<string, WidgetData>,
snapshot: data.snapshot as unknown as MachineSnapshot,
}
}
return { sparseTileData, allTilesLoaded }
},
})
const getMachine = useCallback(
(xt: number, yt: number) =>
xt === lastSubmission?.xt && yt === lastSubmission?.yt
? lastSubmission
: sparseTileData[tileKey(xt, yt)],
[lastSubmission, sparseTileData],
)
const hasBlueprint = useCallback(
(xt: number, yt: number) => machineData?.grid[yt][xt] != null,
[machineData?.grid],
)
return useMemo(
() => ({
isLoading,
allTilesLoaded,
metaMachine: machineData
? {
version,
tilesX,
tilesY,
tileWidth,
tileHeight,
getMachine,
hasBlueprint,
msPerBall,
}
: null,
}),
[
allTilesLoaded,
getMachine,
hasBlueprint,
isLoading,
machineData,
msPerBall,
tileHeight,
tileWidth,
tilesX,
tilesY,
version,
],
)
}
export function useGetPuzzles(): PuzzleOrder[] | undefined {
const { data, isStale } = useQuery({
queryKey: ['puzzle'],
queryFn: async ({ signal }) => {
const { data, response } = await apiClient.GET('/puzzle', {
signal,
})
const workOrder = response.headers.get('X-WorkOrder')
if (!workOrder) {
return
}
const puzzles = data
? Object.entries(data).map(([puzzleId, puzzleData]) => ({
id: puzzleId,
workOrder: workOrder,
inputs: puzzleData.inputs,
outputs: puzzleData.outputs,
}))
: undefined
return puzzles
},
staleTime: Infinity,
})
return !isStale ? data : undefined
}
export function useSubmitBlueprint() {
return useMutation({
mutationFn: async ({
puzzleId,
workOrder,
title,
widgets,
}: {
puzzleId: string
workOrder: string
title: string
widgets: WidgetCollection
}) => {
const { data } = await apiClient.POST('/blueprint/file', {
body: { puzzle: puzzleId, title, widgets },
headers: { 'X-WorkOrder': workOrder },
})
if (!data) {
return null
}
const [blueprintId, [xt, yt]] = data
return { blueprintId, xt, yt }
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['puzzle'] })
},
})
}
================================================
FILE: client/src/components/widgets/Anvil.tsx
================================================
import imgAnvil from '@art/anvil_4x.png'
import { coords } from '../../lib/coords'
import { Angled, Vector } from '../../types'
import { ComicImage } from '../ComicImage'
import { useRigidBody } from '../MachineTileContext'
import { EditableWidget, useSelectHandlers } from '../MachineTileEditor'
import { getPositionStyles } from '../positionStyles'
export interface AnvilWidget extends Vector, Angled {
type: 'anvil'
}
export function AnvilPreview() {
return <ComicImage img={imgAnvil} css={{ width: '70%', height: 'auto' }} />
}
export function Anvil({
id,
onSelect,
x,
y,
angle,
}: AnvilWidget & EditableWidget) {
const width = imgAnvil.width
const height = imgAnvil.height
useRigidBody(
({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {
return {
key: null,
bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)
.setTranslation(...coords.toRapier.vector(x, y))
.setRotation(coords.toRapier.angle(angle)),
colliderDescs: [
// Top
ColliderDesc.triangle(
coords.toRapier.vectorObject(-width / 2, -height / 2),
coords.toRapier.vectorObject(width / 2, -height / 2),
coords.toRapier.vectorObject(0, 0),
).setRestitution(0.7),
// Bottom
ColliderDesc.triangle(
coords.toRapier.vectorObject(-23, height / 2),
coords.toRapier.vectorObject(34, height / 2),
coords.toRapier.vectorObject(-14, 0),
).setRestitution(0.7),
// Center
ColliderDesc.cuboid(...coords.toRapier.lengths(11, 10))
.setTranslation(...coords.toRapier.vector(-6, 0))
.setRestitution(0.7),
],
}
},
[angle, height, width, x, y],
)
return (
<ComicImage
{...useSelectHandlers(id, onSelect)}
img={imgAnvil}
style={getPositionStyles(x, y, angle)}
/>
)
}
================================================
FILE: client/src/components/widgets/AttractorRepulsor.tsx
================================================
import imgAttractor from '@art/attractor_4x.png'
import imgRepulsor from '@art/repulsor_4x.png'
import { useState } from 'react'
import { coords, vectorAngle, vectorDistance } from '../../lib/coords'
import { Sized, Vector } from '../../types'
import { ComicImage } from '../ComicImage'
import { useSensorInTile } from '../MachineTileContext'
import { EditableWidget, useSelectHandlers } from '../MachineTileEditor'
import { useCollider, useLoopHandler } from '../PhysicsContext'
import { getPositionStyles } from '../positionStyles'
export interface AttractorWidget extends Vector, Sized {
type: 'attractor'
}
export interface RepulsorWidget extends Vector, Sized {
type: 'repulsor'
}
export function AttractorPreview() {
return <ComicImage img={imgAttractor} />
}
export function RepulsorPreview() {
return <ComicImage img={imgRepulsor} />
}
const startTime = performance.now()
export function AttractorRepulsor({
id,
onSelect,
isSelected,
className,
x,
y,
width,
strength,
}: Vector & Sized & EditableWidget & { className?: string; strength: number }) {
const fieldSize = width
const radius = 10
const img = strength < 0 ? imgAttractor : imgRepulsor
const boxCollider = useCollider(
({ ColliderDesc }) =>
ColliderDesc.ball(coords.toRapier.length(radius))
.setTranslation(...coords.toRapier.vector(x, y))
.setRestitution(0.5),
[radius, x, y],
)
const fieldCollider = useCollider(
({ ColliderDesc }) =>
ColliderDesc.ball(coords.toRapier.length(fieldSize))
.setTranslation(...coords.toRapier.vector(x, y))
.setSensor(true),
[fieldSize, x, y],
)
const falloffDistance = coords.toRapier.length(fieldSize)
const [angle, setAngle] = useState(0)
const [scale, setScale] = useState(1)
useSensorInTile(
fieldCollider,
(otherCollider) => {
const body = otherCollider.parent()
if (!boxCollider || !boxCollider.isValid() || !body) {
return
}
const distance = vectorDistance(
boxCollider.translation(),
body.translation(),
)
const angle = vectorAngle(boxCollider.translation(), body.translation())
const falloff = Math.max(
0,
Math.pow((falloffDistance - distance) / falloffDistance, 2),
)
const forceVector = {
x: strength * falloff * Math.cos(angle),
y: strength * falloff * Math.sin(angle),
}
body.applyImpulse(forceVector, true)
},
[boxCollider, falloffDistance, strength],
)
useLoopHandler(() => {
const currentTime = performance.now()
if (strength < 0) {
setAngle(((360 * (currentTime - startTime)) / 4000) % 360)
setScale(1 + 0.08 * Math.sin((Math.PI * (currentTime - startTime)) / 900))
} else {
setAngle(360 - (((360 * (currentTime - startTime)) / 20000) % 360))
setScale(1 + 0.05 * Math.sin((Math.PI * (currentTime - startTime)) / 500))
}
}, [strength])
return (
<div
{...useSelectHandlers(id, onSelect)}
css={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: isSelected
? 'radial-gradient(circle closest-side at center, transparent, rgba(0, 0, 0, .25))'
: 'transparent',
width: fieldSize,
height: fieldSize,
borderRadius: '100%',
}}
style={getPositionStyles(x, y, 0)}
>
<ComicImage
img={img}
style={{
transform: `rotate(-${angle}deg) scale(${scale})`,
}}
/>
<div
css={{
position: 'absolute',
width: radius,
height: radius,
}}
className={className}
style={{
borderRadius: '100%',
}}
/>
</div>
)
}
export function Attractor(props: AttractorWidget & EditableWidget) {
return (
<AttractorRepulsor css={{}} {...props} strength={-0.1}></AttractorRepulsor>
)
}
export function Repulsor(props: RepulsorWidget & EditableWidget) {
return <AttractorRepulsor css={{}} {...props} strength={0.5} />
}
================================================
FILE: client/src/components/widgets/BallStand.tsx
================================================
import imgBallStand from '@art/ball-stand_4x.png'
import { coords } from '../../lib/coords'
import { Angled, Vector } from '../../types'
import { ComicImage } from '../ComicImage'
import { useRigidBody } from '../MachineTileContext'
import { EditableWidget, useSelectHandlers } from '../MachineTileEditor'
import { getPositionStyles } from '../positionStyles'
export interface BallStandWidget extends Vector, Angled {
type: 'ballstand'
}
export function BallStandPreview() {
return (
<ComicImage img={imgBallStand} css={{ width: 'auto', height: '80%' }} />
)
}
export function BallStand({
id,
onSelect,
x,
y,
angle,
}: BallStandWidget & EditableWidget) {
const width = imgBallStand.width
const height = imgBallStand.height
useRigidBody(
({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {
return {
key: null,
bodyDesc: new RigidBodyDesc(RigidBodyType.Fixed)
.setTranslation(...coords.toRapier.vector(x, y))
.setRotation(coords.toRapier.angle(angle)),
colliderDescs: [
// Stand
ColliderDesc.cuboid(
...coords.toRapier.lengths(width / 4, height / 2 - 6),
)
.setTranslation(...coords.toRapier.vector(0, 4))
.setRestitution(0.75),
// Base
ColliderDesc.cuboid(
...coords.toRapier.lengths(width / 2, height / 12),
)
.setTranslation(
...coords.toRapier.vector(0, height / 2 - height / 12),
)
.setRestitution(0.75),
// Top left
ColliderDesc.triangle(
coords.toRapier.vectorObject(width / 5, -height / 2),
coords.toRapier.vectorObject(0, -height / 2 + 10),
coords.toRapier.vectorObject(0, -height / 2 + 4),
).setRestitution(0.1),
// Top right
ColliderDesc.triangle(
coords.toRapier.vectorObject(-width / 5, -height / 2),
coords.toRapier.vectorObject(0, -height / 2 + 10),
coords.toRapier.vectorObject(0, -height / 2 + 4),
).setRestitution(0.1),
],
}
},
[angle, height, width, x, y],
)
return (
<ComicImage
{...useSelectHandlers(id, onSelect)}
img={imgBallStand}
style={getPositionStyles(x, y, angle)}
/>
)
}
================================================
FILE: client/src/components/widgets/Balls.tsx
================================================
import ballBlueImg from '@art/ball-blue_4x.png'
import ballGreenImg from '@art/ball-green_4x.png'
import ballRedImg from '@art/ball-red_4x.png'
import ballYellowImg from '@art/ball-yellow_4x.png'
import type RAPIER from '@dimforge/rapier2d'
import type { World } from '@dimforge/rapier2d'
import { ClassNames, css } from '@emotion/react'
import useLatest from '@react-hook/latest'
import { sample } from 'lodash'
import { MutableRefObject, useRef } from 'react'
import { coords } from '../../lib/coords'
import { inBoundsOutset, px } from '../../lib/utils'
import { UserData } from '../../types'
import {
BallData,
BallDestroyReason,
BallFollowCallback,
MachineContextType,
useMachine,
} from '../MachineContext'
import { useRapierEffect } from '../PhysicsContext'
import {
BALL_RADIUS,
BOTTOM_CHUTE_DROP,
BOTTOM_CHUTE_HEIGHT,
} from '../constants'
type BallRecord = BallData & { el: HTMLDivElement }
interface BallActor {
step: () => void
destroy: (reason?: BallDestroyReason) => void
exit: () => void
renew: () => void
die: () => void
follow: (callback: BallFollowCallback) => void
unfollow: () => void
}
export const BASE_BALL_LIFETIME_TICKS = 30 * 60 // 30 seconds at 60tps
const ballStyles = css({
position: 'absolute',
left: 0,
top: 0,
pointerEvents: 'none',
width: 2 * BALL_RADIUS,
height: 2 * BALL_RADIUS,
backgroundSize: 'contain',
zIndex: 5,
contain: 'strict',
'&.blue': {
backgroundImage: `url(${ballBlueImg.url['2x']})`,
},
'&.red': {
backgroundImage: `url(${ballRedImg.url['2x']})`,
},
'&.green': {
backgroundImage: `url(${ballGreenImg.url['2x']})`,
},
'&.yellow': {
backgroundImage: `url(${ballYellowImg.url['2x']})`,
},
})
export const ballClassNames = ['blue', 'red', 'green', 'yellow']
function runBall(
{ id, type, age, snapshot, overrideDamping, el }: BallRecord,
ballClassName: string,
{ RigidBodyDesc, RigidBodyType, ColliderDesc }: typeof RAPIER,
world: World,
machineRef: MutableRefObject<MachineContextType>,
getCurrentTick: () => number,
lifetimeTicks: number,
): BallActor {
el.className = ballClassName
const bodyDesc = new RigidBodyDesc(RigidBodyType.Dynamic)
.setTranslation(snapshot.x, snapshot.y)
.setRotation(snapshot.angle)
.setLinvel(snapshot.vx, snapshot.vy)
.setAngvel(snapshot.va)
.setCcdEnabled(true)
.setUserData({ type: 'BallData', id, ballType: type } satisfies UserData)
const colliderDesc = ColliderDesc.ball(coords.toRapier.length(BALL_RADIUS))
if (type === 2) {
colliderDesc.setRestitution(0.8).setDensity(1)
}
if (type === 3) {
colliderDesc.setMass(0.75)
}
if (type === 4) {
bodyDesc.setLinearDamping(2)
colliderDesc.setDensity(0.3).setRestitution(0.6)
}
if (overrideDamping != null) {
bodyDesc.setLinearDamping(overrideDamping)
}
const body = world.createRigidBody(bodyDesc)
const collider = world.createCollider(colliderDesc, body)
let wasVisible = true
let isDying = false
let isExiting = false
let followCallback: BallFollowCallback | null = null
let renewTick = getCurrentTick() - age
machineRef.current.registerBall(id, type, body, renewTick)
const actor: BallActor = {
step() {
const { simulationBoundsRef, viewBoundsRef } = machineRef.current
const ballAge = getCurrentTick() - renewTick
if (ballAge >= lifetimeTicks && !followCallback) {
this.die()
}
const [x, y] = coords.fromBody.vector(body)
followCallback?.(x, y)
if (
!inBoundsOutset(
x,
y,
BOTTOM_CHUTE_DROP + BOTTOM_CHUTE_HEIGHT,
simulationBoundsRef.current,
)
) {
machineRef.current?.destroyBall(id)
el.style.display = 'none'
return
}
const isVisible = inBoundsOutset(x, y, BALL_RADIUS, viewBoundsRef.current)
if (isVisible != wasVisible) {
el.style.display = isVisible ? 'block' : 'none'
}
if (!isVisible && isExiting) {
machineRef.current?.destroyBall(id)
return
}
if (isVisible) {
el.style.transform = `translate(${px(x - BALL_RADIUS)}, ${px(y - BALL_RADIUS)})`
}
wasVisible = isVisible
},
destroy() {
el.remove()
machineRef.current.unregisterBall(id)
// Immediately destroying the body seems to cause panics when iterating over colliders.
setTimeout(() => {
world.removeRigidBody(body)
}, 0)
},
exit() {
isExiting = true
},
renew() {
renewTick = getCurrentTick()
machineRef.current.registerBall(id, type, body, renewTick)
},
die() {
if (isDying) {
return
}
isDying = true
el.style.transition = 'opacity 200ms ease-in'
el.style.opacity = '0'
setTimeout(() => {
world.removeCollider(collider, false)
}, 0)
setTimeout(() => {
machineRef.current?.destroyBall(id, 'expiry')
}, 200)
},
follow(callback: BallFollowCallback) {
followCallback = callback
},
unfollow() {
followCallback = null
},
}
actor.step()
return actor
}
export function BallsRunner({
lifetimeTicks,
ballClassName,
}: {
lifetimeTicks: number
ballClassName: string
}) {
const machine = useMachine()
const parentRef = useRef<HTMLDivElement>(null)
const { events } = machine
const machineRef = useLatest(machine)
useRapierEffect(
({ events: worldEvents, rapier, world, getCurrentTick }) => {
const actors: Record<string, BallActor> = {}
let followingId: string | undefined = undefined
function handleCreateBall(ball: BallData) {
const el = document.createElement('div')
actors[ball.id] = runBall(
{ ...ball, el },
`${ballClassName} ${ballClassNames[ball.type - 1]}`,
rapier,
world,
machineRef,
getCurrentTick,
lifetimeTicks,
)
parentRef.current?.appendChild(el)
}
function handleDestroyBall(id: string) {
actors[id]?.destroy()
delete actors[id]
}
function handleExitBall(id: string) {
actors[id]?.exit()
}
function handleRenewBall(id: string) {
actors[id]?.renew()
}
function handleKillBall(id: string) {
actors[id]?.die()
}
function handleFollowBall(callback: BallFollowCallback) {
handleUnfollowBall()
followingId = sample(Object.keys(actors))
if (!followingId) {
return
}
actors[followingId].follow(callback)
}
function handleUnfollowBall() {
if (followingId && actors[followingId]) {
actors[followingId].unfollow()
}
}
function handleStep() {
Object.values(actors).forEach((actor) => actor.step())
}
events.on('createBall', handleCreateBall)
events.on('destroyBall', handleDestroyBall)
events.on('exitBall', handleExitBall)
events.on('renewBall', handleRenewBall)
events.on('killBall', handleKillBall)
events.on('followBall', handleFollowBall)
events.on('unfollowBall', handleUnfollowBall)
worldEvents.on('step', handleStep)
return () => {
events.off('createBall', handleCreateBall)
events.off('destroyBall', handleDestroyBall)
events.off('exitBall', handleExitBall)
events.off('renewBall', handleRenewBall)
events.off('killBall', handleKillBall)
events.off('followBall', handleFollowBall)
events.off('unfollowBall', handleUnfollowBall)
worldEvents.off('step', handleStep)
Object.values(actors).forEach((actor) => actor.destroy())
}
},
[ballClassName, events, lifetimeTicks, machineRef],
)
return <div ref={parentRef} />
}
export function Balls({
lifetimeTicks = BASE_BALL_LIFETIME_TICKS,
}: {
lifetimeTicks?: number
}) {
return (
<ClassNames>
{({ css }) => (
<BallsRunner
lifetimeTicks={lifetimeTicks}
ballClassName={css(ballStyles)}
/>
)}
</ClassNames>
)
}
================================================
FILE: client/src/components/widgets/Board.tsx
================================================
import imgBoard from '@art/board_4x.png'
import { coords } from '../../lib/coords'
import { Angled, Vector } from '../../types'
import { ComicImage } from '../ComicImage'
import { EditableWidget, useSelectHandlers } from '../MachineTileEditor'
import { useCollider } from '../PhysicsContext'
import { getPositionStyles } from '../positionStyles'
export interface BoardWidget extends Vector, Angled {
type: 'board'
}
export function BoardPreview() {
return (
<ComicImage
img={imgBoard}
css={{ width: '100%', height: 'auto', transform: 'rotate(-45deg)' }}
/>
)
}
export function Board({
id,
onSelect,
x,
y,
angle,
}: BoardWidget & EditableWidget) {
const width = imgBoard.width
const height = imgBoard.height
useCollider(
({ ColliderDesc }) =>
ColliderDesc.cuboid(...coords.toRapier.lengths(width / 2, height / 2))
.setTranslation(...coords.toRapier.vector(x, y))
.setRotation(coords.toRapier.angle(angle))
.setRestitution(0.5),
[angle, height, width, x, y],
)
return (
<ComicImage
{...useSelectHandlers(id, onSelect)}
img={imgBoard}
style={getPositionStyles(x, y, angle)}
/>
)
}
================================================
FILE: client/src/components/widgets/Boat.tsx
================================================
import boatImage from '@art/boat_4x.png'
import { type Vector } from '@dimforge/rapier2d'
import { coords } from '../../lib/coords'
import { ComicImage } from '../ComicImage'
import { useRigidBody } from '../MachineTileContext'
import { EditableWidget } from '../MachineTileEditor'
import { useLoopHandler, useRapierEffect } from '../PhysicsContext'
import { getPositionStyles, usePositionedBodyRef } from '../positionStyles'
export default function Boat({ id, x, y }: Vector & EditableWidget) {
const radius = 30
const bodyRef = useRigidBody(
({ RigidBodyDesc, ColliderDesc, RigidBodyType }) => {
return {
key: id,
bodyDesc: new RigidBodyDesc(RigidBodyType.Dynamic)
.setLinearDamping(6)
.setAngularDamping(2),
colliderDescs: [
// Weight
ColliderDesc.capsule(
coords.toRapier.length(boatImage.width / 3 - radius),
coords.toRapier.length(radius),
)
.setTranslation(...coords.toRapier.vector(30, 45))
.setRotation(Math.PI / 2)
.setMass(12),
// Sensor
ColliderDesc.cuboid(
...coords.toRapier.lengths(
boatImage.width / 3,
boatImage.height / 2,
),
)
.setTranslation(...coords.toRapier.vector(0, 10))
.setSensor(true),
],
}
},
[id],
)
useRapierEffect(() => {
const { current: body } = bodyRef
if (!body) {
return
}
body.setTranslation(coords.toRapier.vectorObject(x, y), true)
}, [bodyRef, x, y])
useLoopHandler(
({ world }) => {
const { current: body } = bodyRef
if (!body) {
return
}
const rotation = body.rotation()
const springConstant = -1
body.resetTorques(true)
body.applyTorqueImpulse(rotation * springConstant, true)
let countTouchingWater = 0
world.intersectionPairsWith(body.collider(1), () => {
countTouchingWater++
})
if (countTouchingWater) {
body.applyImpulse(
{ x: 0, y: coords.toRapier.y(-6 * countTouchingWater) },
true,
)
}
},
[bodyRef],
)
return (
<ComicImage
ref={usePositionedBodyRef(bodyRef, {
width: boatImage.width,
height: boatImage.height,
initialX: x,
initialY: y,
})}
css={{ zIndex: 20 }}
style={getPositionStyles(x, y)}
img={boatImage}
></ComicImage>
)
}
================================================
FILE: client/src/components/widgets/BottomChute.tsx
================================================
import imgBottomChute from '@art/chute-0v_4x.png'
import imgBottomChute25L from '@art/chute-25l_4x.png'
import imgBottomChute25R from '@art/chute-25r_4x.png'
import imgBottomChute50L from '@art/chute-50l_4x.png'
import imgBottomChute50R from '@art/chute-50r_4x.png'
import imgBottomChute75L from '@art/chute-75l_4x.png'
import imgBottomChute75R from '@art/chute-75r_4x.png'
import { coords } from '../../lib/coords'
import { Angled, Ball, Vector, isBall } from '../../types'
import { ComicImage } from '../ComicImage'
import { useMachine } from '../MachineContext'
import { useCollider, useCollisionHandler } from '../PhysicsContext'
import { BOTTOM_CHUTE_EXIT_OFFSET } from '../constants'
import { getPositionStyles } from '../positionStyles'
export function BottomChute({
x,
y,
angle,
onReceiveBall,
}: {
onReceiveBall: (ball: Ball) => void
} & Angled &
Vector) {
const { destroyBall } = useMachine()
const width = imgBottomChute.width
const height = imgBottomChute.height
const sensorCollider = useCollider(
({ ColliderDesc, ActiveEvents }) =>
ColliderDesc.cuboid(...coords.toRapier.lengths(width / 2, height / 8))
.setTranslation(
...coords.toRapier.vector(x, y + BOTTOM_CHUTE_EXIT_OFFSET),
)
.setSensor(true)
.setActiveEvents(ActiveEvents.COLLISION_EVENTS),
[height, width, x, y],
)
useCollisionHandler(
'start',
sensorCollider,
(otherCollider) => {
const body = otherCollider.parent()
if (!body || !isBall(body)) {
return
}
onReceiveBall(body)
destroyBall(body.userData.id)
},
[],
)
const angleDeg = angle * (180 / Math.PI) - 90
let img = imgBottomChute
if (angleDeg < -40) {
img = imgBottomChute75R
} else if (angleDeg < -10) {
img = imgBottomChute50R
} else if (angleDeg < -2) {
img = imgBottomChute25R
} else if (angleDeg < 2) {
img = imgBottomChute
} else if (angleDeg < 10) {
img = imgBottomChute25L
} else if (angleDeg < 40) {
img = imgBottomChute50L
} else {
img = imgBottomChute75L
}
return (
<ComicImage
img={img}
css={{ zIndex: 20 }}
style={getPositionStyles(x, y)}
/>
)
}
================================================
FILE: client/src/components/widgets/BottomPit.tsx
================================================
import imgBottomPit from '@art/bottom_pit_4x.png'
import { coords } from '../../lib/coords'
import { Vector } from '../../types'
import { ComicImage } from '../ComicImage'
import { useCollider } from '../PhysicsContext'
import {
BOTTOM_PIT_EDGE_WIDTH,
BOTTOM_PIT_HEIGHT,
BOTTOM_PIT_WIDTH,
} from '../constants'
import { getPositionStyles } from '../positionStyles'
export function BottomPit({ x, y }: Vector) {
const width = BOTTOM_PIT_WIDTH
const height = BOTTOM_PIT_HEIGHT
const edgeWidth = BOTTOM_PIT_EDGE_WIDTH
const bottomHeight = 92
useCollider(
({ ColliderDesc }) =>
ColliderDesc.cuboid(...coords.toRapier.lengths(edgeWidth / 2, height / 2))
.setTranslation(
...coords.toRapier.vector(x - width / 2 + edgeWidth / 2, y),
)
.setRestitution(0.1),
[edgeWidth, height, width, x, y],
)
useCollider(
({ ColliderDesc }) =>
ColliderDesc.cuboid(...coords.toRapier.lengths(edgeWidth / 2, height / 2))
.setTranslation(
...coords.toRapier.vector(x + width / 2 - edgeWidth / 2, y),
)
.setRestitution(0.1),
[edgeWidth, height, width, x, y],
)
useCollider(
({ ColliderDesc }) =>
ColliderDesc.cuboid(
...coords.toRapier.lengths(width / 2, bottomHeight / 2),
)
.setTranslation(
...coords.toRapier.vector(x, y + height / 2 - bottomHeight / 2),
)
.setRestitution(0.1),
[height, width, x, y],
)
useCollider(
({ ColliderDesc }) =>
ColliderDesc.cuboid(
...coords.toRapier.lengths(width / 2, bottomHeight / 2),
)
.setTranslation(
...coords.toRapier.vector(x, y + height / 2 - bottomHeight / 2),
)
.setRestitution(0.1),
[height, width, x, y],
)
return <ComicImage img={imgBottomPit} style={getPositionStyles(x, y)} />
}
================================================
FILE: client/src/components/widgets/BottomTank.tsx
================================================
import tankBlueOpenImg from '@art/tank-blue-open_4x.png'
import tankBlueImg from '@art/tank-blue_4x.png'
import tankGreenOpenImg from '@art/tank-green-open_4x.png'
import tankGreenImg from '@art/tank-green_4x.png'
import tankRedOpenImg from '@art/tank-red-open_4x.png'
import tankRedImg from '@art/tank-red_4x.png'
import tankYellowOpenImg from '@art/tank-yellow-open_4x.png'
import tankYellowImg from '@art/tank-yellow_4x.png'
import { useMemo, useState } from 'react'
import { coords } from '../../lib/coords'
import { BallType, Vector, isBall } from '../../types'
import { ComicImageAnimation } from '../ComicImage'
import { useMachine } from '../MachineContext'
import {
TICK_MS,
useCollider,
useCollisionHandler,
useLoopHandler,
} from '../PhysicsContext'
import { BOTTOM_TANK_HEIGHT, BOTTOM_TANK_WIDTH } from '../constants'
import { getPositionStyles } from '../positionStyles'
import { lineCuboid } from './lib/lineCuboid'
const tankImages = [
[tankBlueImg, tankBlueOpenImg],
[tankRedImg, tankRedOpenImg],
[tankGreenImg, tankGreenOpenImg],
[tankYellowImg, tankYellowOpenImg],
]
const intervalTicks = Math.floor((12 * 1000) / TICK_MS)
const openTime = Math.floor(800 / TICK_MS)
export function BottomTank({
x,
y,
type,
}: {
type: BallType
} & Vector) {
const { killBall } = useMachine()
const [isOpen, setOpen] = useState(false)
const basis = useMemo(() => ({ xBasis: -x, yBasis: -y }), [x, y])
// Left top funnel
useCollider(
({ ColliderDesc, CoefficientCombineRule }) =>
lineCuboid(
ColliderDesc,
{ x1: -43, y1: -209, x2: -28, y2: -180, thickness: 2 },
basis,
)
.setRestitution(0.05)
.setRestitutionCombineRule(CoefficientCombineRule.Min),
[basis],
)
// Right top funnel
useCollider(
({ ColliderDesc, CoefficientCombineRule }) =>
lineCuboid(
ColliderDesc,
{ x1: 43, y1: -209, x2: 28, y2: -180, thickness: 2 },
basis,
)
.setRestitution(0.05)
.setRestitutionCombineRule(CoefficientCombineRule.Min),
[basis],
)
// Left top
useCollider(
({ ColliderDesc }) =>
lineCuboid(
ColliderDesc,
{ x1: -85, y1: -164, x2: -32, y2: -190, thickness: 17 },
basis,
),
[basis],
)
// Right top
useCollider(
({ ColliderDesc }) =>
lineCuboid(
ColliderDesc,
{ x1: 85, y1: -164, x2: 32, y2: -190, thickness: 17 },
basis,
),
[basis],
)
// Left edge
useCollider(
({ ColliderDesc }) =>
lineCuboid(
ColliderDesc,
{ x1: -79, y1: -170, x2: -79, y2: 205, thickness: 20 },
basis,
gitextract_ufpta25o/
├── .gitignore
├── CHANGELOG.md
├── Gen/
│ └── Main.hs
├── LICENSE
├── README.md
├── api-spec.json
├── app/
│ └── Main.hs
├── client/
│ ├── babel.config.json
│ ├── comic.json
│ ├── eslint.config.js
│ ├── loaders/
│ │ └── comic-image-loader.js
│ ├── package.json
│ ├── prettier.config.mjs
│ ├── src/
│ │ ├── api.ts
│ │ ├── components/
│ │ │ ├── BallPitMechanism.tsx
│ │ │ ├── CenteredSlippyMap.tsx
│ │ │ ├── Comic.tsx
│ │ │ ├── ComicBrowseView.tsx
│ │ │ ├── ComicImage.tsx
│ │ │ ├── ComicPuzzleView.tsx
│ │ │ ├── DebugOverlay.tsx
│ │ │ ├── EditorTutorials.tsx
│ │ │ ├── FullscreenComicContainer.tsx
│ │ │ ├── InnerComicBorder.tsx
│ │ │ ├── LoadingSpinner.tsx
│ │ │ ├── MachineContext.tsx
│ │ │ ├── MachineTileContext.tsx
│ │ │ ├── MachineTileEditor.tsx
│ │ │ ├── MachineTilePlaceholder.tsx
│ │ │ ├── MetaMachineView.tsx
│ │ │ ├── NamePrompt.tsx
│ │ │ ├── PhysicsContext.tsx
│ │ │ ├── SwooshyDialog.tsx
│ │ │ ├── WidgetPalette.tsx
│ │ │ ├── constants.tsx
│ │ │ ├── moderation/
│ │ │ │ ├── BlueprintButton.tsx
│ │ │ │ ├── ContextGridForMachineAt.tsx
│ │ │ │ ├── LiveMachinePreview.tsx
│ │ │ │ ├── ModMachineTileView.tsx
│ │ │ │ ├── ModTileInputOutputView.tsx
│ │ │ │ ├── Moderator.tsx
│ │ │ │ ├── SelectTileForm.tsx
│ │ │ │ ├── interestingWeights.ts
│ │ │ │ ├── modTypes.d.ts
│ │ │ │ ├── modUtils.ts
│ │ │ │ └── moderatorClient.ts
│ │ │ ├── positionStyles.ts
│ │ │ ├── useLocationHashParams.tsx
│ │ │ ├── useMetaMachineClient.ts
│ │ │ └── widgets/
│ │ │ ├── Anvil.tsx
│ │ │ ├── AttractorRepulsor.tsx
│ │ │ ├── BallStand.tsx
│ │ │ ├── Balls.tsx
│ │ │ ├── Board.tsx
│ │ │ ├── Boat.tsx
│ │ │ ├── BottomChute.tsx
│ │ │ ├── BottomPit.tsx
│ │ │ ├── BottomTank.tsx
│ │ │ ├── Brick.tsx
│ │ │ ├── CatSwat.tsx
│ │ │ ├── CircleGauge.tsx
│ │ │ ├── Cup.tsx
│ │ │ ├── Cushion.tsx
│ │ │ ├── Fan.tsx
│ │ │ ├── Hammer.tsx
│ │ │ ├── Hook.tsx
│ │ │ ├── InputOutput.tsx
│ │ │ ├── LeftBumper.tsx
│ │ │ ├── MachineFrame.tsx
│ │ │ ├── OutputValidator.tsx
│ │ │ ├── Prism.tsx
│ │ │ ├── QuantumGate.tsx
│ │ │ ├── RightBumper.tsx
│ │ │ ├── RoundBumper.tsx
│ │ │ ├── SpawnInput.tsx
│ │ │ ├── Sticker.tsx
│ │ │ ├── Sword.tsx
│ │ │ ├── Wheel.tsx
│ │ │ ├── index.tsx
│ │ │ └── lib/
│ │ │ ├── ball.ts
│ │ │ └── lineCuboid.ts
│ │ ├── custom.d.ts
│ │ ├── generated/
│ │ │ └── api-spec.d.ts
│ │ ├── image.d.ts
│ │ ├── index.ejs
│ │ ├── index.tsx
│ │ ├── lib/
│ │ │ ├── coords.ts
│ │ │ ├── snapshot.tsx
│ │ │ ├── tiles.tsx
│ │ │ └── utils.ts
│ │ ├── page/
│ │ │ ├── demo-editor.tsx
│ │ │ ├── demo-map.tsx
│ │ │ ├── demo-viewer.tsx
│ │ │ ├── fixtures/
│ │ │ │ ├── demoMachine.tsx
│ │ │ │ └── emptyMachine.tsx
│ │ │ ├── moderator.tsx
│ │ │ └── page.ejs
│ │ └── types.ts
│ ├── tsconfig.json
│ └── webpack.config.js
├── config/
│ ├── incredible.toml
│ └── machine.json
├── docs/
│ └── Main.hs
├── flake.nix
├── incredible.cabal
├── nix/
│ ├── deploy.nix
│ ├── digital-ocean/
│ │ ├── digital-ocean-config.nix
│ │ ├── digital-ocean-custom-image.nix
│ │ └── make-single-disk-zfs-image.nix
│ ├── incredible-cfg.nix
│ ├── incredible-digital-ocean.nix
│ ├── incredible-frontend.nix
│ ├── incredible-qemu.nix
│ ├── incredible-server.nix
│ ├── profiles/
│ │ └── staging.nix
│ └── users/
│ └── deploy.nix
├── src/
│ └── Incredible/
│ ├── API.hs
│ ├── AntiEvil.hs
│ ├── App.hs
│ ├── Config.hs
│ ├── Data.hs
│ ├── DataStore/
│ │ ├── Memory.hs
│ │ └── Redis.hs
│ ├── DataStore.hs
│ └── Puzzle.hs
└── test/
└── Main.hs
SYMBOL INDEX (346 symbols across 81 files)
FILE: client/loaders/comic-image-loader.js
function processImage (line 5) | async function processImage(inputBuffer) {
function downscale (line 48) | function downscale(inputBuffer) {
FILE: client/src/api.ts
method onResponse (line 12) | async onResponse(res) {
FILE: client/src/components/BallPitMechanism.tsx
function BallPitMechanism (line 35) | function BallPitMechanism({
FILE: client/src/components/CenteredSlippyMap.tsx
type SlippyMapRef (line 13) | interface SlippyMapRef {
type CenteredSlippyMapProps (line 21) | interface CenteredSlippyMapProps {
FILE: client/src/components/Comic.tsx
type MetaMachineLink (line 21) | interface MetaMachineLink {
function useHashLink (line 27) | function useHashLink(): {
function Comic (line 70) | function Comic() {
FILE: client/src/components/ComicBrowseView.tsx
function findRandomTile (line 25) | function findRandomTile(metaMachine: MetaMachineInfo) {
function ComicBrowseView (line 40) | function ComicBrowseView({
FILE: client/src/components/ComicImage.tsx
type ComicImageProps (line 11) | interface ComicImageProps
FILE: client/src/components/ComicPuzzleView.tsx
function saveSolution (line 21) | function saveSolution(location: {
function ComicPuzzleView (line 39) | function ComicPuzzleView({
FILE: client/src/components/DebugOverlay.tsx
function DebugOverlay (line 6) | function DebugOverlay({
FILE: client/src/components/EditorTutorials.tsx
type TutorialKey (line 17) | type TutorialKey = keyof typeof tutorials
type TutorialState (line 18) | type TutorialState = { [K in TutorialKey]: boolean }
constant STORE_KEY (line 20) | const STORE_KEY = 'contraptionTips'
function loadState (line 22) | function loadState(): TutorialState {
function saveState (line 36) | function saveState(state: TutorialState) {
function useTutorials (line 40) | function useTutorials() {
function EditorTutorials (line 74) | function EditorTutorials({
FILE: client/src/components/FullscreenComicContainer.tsx
function useIsFullscreen (line 15) | function useIsFullscreen() {
type DisplayContextType (line 34) | type DisplayContextType = {
function useDisplayState (line 44) | function useDisplayState() {
function FullscreenComicContainer (line 48) | function FullscreenComicContainer({
FILE: client/src/components/InnerComicBorder.tsx
function InnerComicBorder (line 4) | function InnerComicBorder({
FILE: client/src/components/LoadingSpinner.tsx
function LoadingSpinner (line 34) | function LoadingSpinner({ className }: { className?: string }) {
FILE: client/src/components/MachineContext.tsx
type BallData (line 23) | interface BallData {
type BallDestroyReason (line 31) | type BallDestroyReason = 'expiry'
type BallFollowCallback (line 33) | type BallFollowCallback = (x: number, y: number) => void
type MachineEvents (line 35) | type MachineEvents = {
type ActiveBalls (line 46) | type ActiveBalls = Record<
type CreateBallOptions (line 55) | type CreateBallOptions = {
type MachineContextType (line 61) | type MachineContextType = {
type MachineContextProviderRef (line 91) | interface MachineContextProviderRef {
function useMachine (line 319) | function useMachine() {
FILE: client/src/components/MachineTileContext.tsx
type ActiveBodies (line 33) | type ActiveBodies = Record<string, RigidBody>
type MachineTileContextType (line 35) | type MachineTileContextType = {
type MachineTileContextProviderRef (line 45) | interface MachineTileContextProviderRef {
function useRigidBody (line 197) | function useRigidBody(
function useSensorInTile (line 238) | function useSensorInTile(
function useMachineTile (line 263) | function useMachineTile() {
FILE: client/src/components/MachineTileEditor.tsx
constant DEG_TO_RAD (line 43) | const DEG_TO_RAD = Math.PI / 180
type EditableWidget (line 45) | interface EditableWidget {
type Selection (line 54) | interface Selection {
function useSelectHandlers (line 59) | function useSelectHandlers(
type Widgets (line 74) | type Widgets = Record<string, WidgetData>
function useWheelSpeed (line 78) | function useWheelSpeed(
function MachineTileEditor (line 127) | function MachineTileEditor({
FILE: client/src/components/MachineTilePlaceholder.tsx
function MachineTilePlaceholder (line 31) | function MachineTilePlaceholder({
FILE: client/src/components/MetaMachineView.tsx
function draw (line 62) | function draw() {
type SlippyMetaMachineViewProps (line 98) | interface SlippyMetaMachineViewProps extends MetaMachineInfo {
type SlippyMetaMachineRef (line 107) | interface SlippyMetaMachineRef {
FILE: client/src/components/NamePrompt.tsx
function NamePrompt (line 61) | function NamePrompt({
FILE: client/src/components/PhysicsContext.tsx
type LoopEvents (line 27) | type LoopEvents = {
constant GRAVITY (line 33) | const GRAVITY = { x: 0.0, y: -9.81 }
constant TICK_MS (line 34) | const TICK_MS = 1000 / 60
type PhysicsContextType (line 36) | interface PhysicsContextType {
function PhysicsContextProvider (line 46) | function PhysicsContextProvider({
function useRapierEffect (line 188) | function useRapierEffect(
function useCollider (line 204) | function useCollider(
function useImpulseJoint (line 230) | function useImpulseJoint(
function useLoopHandler (line 264) | function useLoopHandler(
function useCollisionHandler (line 286) | function useCollisionHandler(
function usePhysicsLoaded (line 315) | function usePhysicsLoaded() {
function PhysicsLoader (line 319) | function PhysicsLoader({
function deferATick (line 333) | function deferATick(callback: () => void) {
FILE: client/src/components/SwooshyDialog.tsx
function SwooshyDialog (line 12) | function SwooshyDialog({
FILE: client/src/components/WidgetPalette.tsx
function ToolButton (line 14) | function ToolButton({
function WidgetPalette (line 43) | function WidgetPalette({
FILE: client/src/components/constants.tsx
constant BALL_RADIUS (line 5) | const BALL_RADIUS = 8
constant INPUT_SPINNER_SIZE (line 6) | const INPUT_SPINNER_SIZE = 42
constant INPUT_SPINNER_SPEED (line 7) | const INPUT_SPINNER_SPEED = 2
constant INPUT_TEETH_COUNT (line 8) | const INPUT_TEETH_COUNT = 8
constant INPUT_WIDTH (line 9) | const INPUT_WIDTH = 2 * INPUT_SPINNER_SIZE + 5
constant MAX_WIDGET_COUNT (line 10) | const MAX_WIDGET_COUNT = 100
constant TRIANGLE_BUMPER_SENSOR_OFFSET (line 11) | const TRIANGLE_BUMPER_SENSOR_OFFSET = 0.2
constant TRIANGLE_BUMPER_RADIUS_RATIO (line 12) | const TRIANGLE_BUMPER_RADIUS_RATIO = 0.14159
constant TRIANGLE_BUMPER_SENSOR_FUDGE (line 13) | const TRIANGLE_BUMPER_SENSOR_FUDGE = 2.2
constant TRIANGLE_BUMPER_STRENGTH (line 14) | const TRIANGLE_BUMPER_STRENGTH = 35
constant ROUND_BUMPER_STRENGTH (line 15) | const ROUND_BUMPER_STRENGTH = 35
constant BONK_ANIMATION_DELAY_MS (line 16) | const BONK_ANIMATION_DELAY_MS = 200
constant TRIANGLE_BUMPER_CONTACT_DISTANCE (line 17) | const TRIANGLE_BUMPER_CONTACT_DISTANCE = 0.1
constant ROAND_BUMPER_RADIUS_RATIO (line 18) | const ROAND_BUMPER_RADIUS_RATIO = 0.49
constant ROUND_BUMPER_FALLBACK_RATIO (line 19) | const ROUND_BUMPER_FALLBACK_RATIO = 0.9
constant BOTTOM_PIT_WIDTH (line 20) | const BOTTOM_PIT_WIDTH = imgBottomPit.width
constant BOTTOM_PIT_HEIGHT (line 21) | const BOTTOM_PIT_HEIGHT = imgBottomPit.height
constant BOTTOM_PIT_EDGE_WIDTH (line 22) | const BOTTOM_PIT_EDGE_WIDTH = 94
constant BOTTOM_MECHANISM_HEIGHT (line 23) | const BOTTOM_MECHANISM_HEIGHT = 3000
constant BOTTOM_CHUTE_DROP (line 24) | const BOTTOM_CHUTE_DROP = 50
constant BOTTOM_CHUTE_EXIT_OFFSET (line 25) | const BOTTOM_CHUTE_EXIT_OFFSET = 24
constant BOTTOM_CHUTE_HEIGHT (line 26) | const BOTTOM_CHUTE_HEIGHT = imgBottomChute.height
constant BOTTOM_TANK_WIDTH (line 27) | const BOTTOM_TANK_WIDTH = imgBottomTank.width
constant BOTTOM_TANK_HEIGHT (line 28) | const BOTTOM_TANK_HEIGHT = imgBottomTank.height
constant BOTTOM_TANK_SPACING (line 29) | const BOTTOM_TANK_SPACING = 100
constant BOTTOM_TANK_TARGET_TOP (line 30) | const BOTTOM_TANK_TARGET_TOP = 8
FILE: client/src/components/moderation/BlueprintButton.tsx
function BlueprintButton (line 8) | function BlueprintButton({
FILE: client/src/components/moderation/ContextGridForMachineAt.tsx
constant TILE_WIDTH (line 11) | const TILE_WIDTH = 100
function OOBTile (line 26) | function OOBTile() {
function EmptyTile (line 42) | function EmptyTile({
function ContextGridForMachineAt (line 70) | function ContextGridForMachineAt({
FILE: client/src/components/moderation/LiveMachinePreview.tsx
constant MIN_SECONDS_TO_MOD (line 33) | const MIN_SECONDS_TO_MOD = 30
function useCountdown (line 35) | function useCountdown(initialSeconds: number) {
function ValidationLine (line 58) | function ValidationLine({
function LiveMachinePreview (line 79) | function LiveMachinePreview({
FILE: client/src/components/moderation/ModMachineTileView.tsx
function ModMachineTileView (line 8) | function ModMachineTileView({
FILE: client/src/components/moderation/ModTileInputOutputView.tsx
function TinyInputOutput (line 32) | function TinyInputOutput({
function ModTileInputOutputView (line 63) | function ModTileInputOutputView({ puzzle }: { puzzle: Puzzle }) {
FILE: client/src/components/moderation/Moderator.tsx
function useModHashParams (line 22) | function useModHashParams(): {
function BlueprintModerator (line 59) | function BlueprintModerator({
function Moderator (line 292) | function Moderator() {
FILE: client/src/components/moderation/SelectTileForm.tsx
function SelectTileForm (line 3) | function SelectTileForm({
FILE: client/src/components/moderation/modTypes.d.ts
type ModLocation (line 3) | type ModLocation = {
type ServerBlueprint (line 10) | type ServerBlueprint = components['schemas']['Blueprint']
type CandidateMap (line 12) | type CandidateMap = Map<string, ServerBlueprint>
type ModMachine (line 14) | type ModMachine = components['schemas']['VersionedMachine ModData']
FILE: client/src/components/moderation/modUtils.ts
function getRandEmptyNearTile (line 13) | function getRandEmptyNearTile(
function getEmptyTile (line 38) | function getEmptyTile(modMachine: ModMachine): ModLocation {
function calculateInterest (line 60) | function calculateInterest(b: ServerBlueprint): number {
function sortCandidateMap (line 69) | function sortCandidateMap(
function locFromPosition (line 80) | function locFromPosition(
FILE: client/src/components/moderation/moderatorClient.ts
function puzzleQueryOptions (line 12) | function puzzleQueryOptions(puzzleId: string | undefined) {
function useModeratorMachine (line 32) | function useModeratorMachine() {
function useCandidateBlueprints (line 46) | function useCandidateBlueprints({ puzzleId }: { puzzleId: string }) {
function useBlueprint (line 67) | function useBlueprint(blueprintId: string | undefined) {
function useContextBlueprints (line 71) | function useContextBlueprints(blueprintIds: Array<string | undefined>) {
function useContextPuzzles (line 77) | function useContextPuzzles(puzzleIds: Array<string | undefined>) {
function useApproveBlueprint (line 83) | function useApproveBlueprint() {
function useBurnBlueprint (line 116) | function useBurnBlueprint() {
function useReissuePuzzle (line 141) | function useReissuePuzzle() {
FILE: client/src/components/positionStyles.ts
function getPositionStyles (line 14) | function getPositionStyles(x: number, y: number, angle: number = 0) {
function usePositionedBodyRef (line 23) | function usePositionedBodyRef<T extends HTMLElement>(
FILE: client/src/components/useLocationHashParams.tsx
function paramToNumber (line 3) | function paramToNumber(text: string | null | undefined) {
function useLocationHashParams (line 16) | function useLocationHashParams() {
FILE: client/src/components/useMetaMachineClient.ts
type TileData (line 21) | interface TileData {
type GetMachineFunction (line 29) | type GetMachineFunction = (
type MetaMachineInfo (line 34) | interface MetaMachineInfo {
type MetaMachineClient (line 45) | interface MetaMachineClient {
type SavedBlueprintLocation (line 51) | interface SavedBlueprintLocation {
type SavedMachine (line 57) | type SavedMachine = SavedBlueprintLocation & TileData
function blueprintQueryOptions (line 59) | function blueprintQueryOptions(blueprintid: string | undefined) {
function useMetaMachineClient (line 79) | function useMetaMachineClient({
function useGetPuzzles (line 211) | function useGetPuzzles(): PuzzleOrder[] | undefined {
function useSubmitBlueprint (line 241) | function useSubmitBlueprint() {
FILE: client/src/components/widgets/Anvil.tsx
type AnvilWidget (line 9) | interface AnvilWidget extends Vector, Angled {
function AnvilPreview (line 13) | function AnvilPreview() {
function Anvil (line 17) | function Anvil({
FILE: client/src/components/widgets/AttractorRepulsor.tsx
type AttractorWidget (line 12) | interface AttractorWidget extends Vector, Sized {
type RepulsorWidget (line 16) | interface RepulsorWidget extends Vector, Sized {
function AttractorPreview (line 20) | function AttractorPreview() {
function RepulsorPreview (line 24) | function RepulsorPreview() {
function AttractorRepulsor (line 30) | function AttractorRepulsor({
function Attractor (line 144) | function Attractor(props: AttractorWidget & EditableWidget) {
function Repulsor (line 150) | function Repulsor(props: RepulsorWidget & EditableWidget) {
FILE: client/src/components/widgets/BallStand.tsx
type BallStandWidget (line 9) | interface BallStandWidget extends Vector, Angled {
function BallStandPreview (line 13) | function BallStandPreview() {
function BallStand (line 19) | function BallStand({
FILE: client/src/components/widgets/Balls.tsx
type BallRecord (line 28) | type BallRecord = BallData & { el: HTMLDivElement }
type BallActor (line 30) | interface BallActor {
constant BASE_BALL_LIFETIME_TICKS (line 40) | const BASE_BALL_LIFETIME_TICKS = 30 * 60 // 30 seconds at 60tps
function runBall (line 68) | function runBall(
function BallsRunner (line 209) | function BallsRunner({
function Balls (line 304) | function Balls({
FILE: client/src/components/widgets/Board.tsx
type BoardWidget (line 9) | interface BoardWidget extends Vector, Angled {
function BoardPreview (line 13) | function BoardPreview() {
function Board (line 22) | function Board({
FILE: client/src/components/widgets/Boat.tsx
function Boat (line 10) | function Boat({ id, x, y }: Vector & EditableWidget) {
FILE: client/src/components/widgets/BottomChute.tsx
function BottomChute (line 16) | function BottomChute({
FILE: client/src/components/widgets/BottomPit.tsx
function BottomPit (line 13) | function BottomPit({ x, y }: Vector) {
FILE: client/src/components/widgets/BottomTank.tsx
function BottomTank (line 34) | function BottomTank({
FILE: client/src/components/widgets/Brick.tsx
type BrickWidget (line 9) | interface BrickWidget extends Vector, Angled {
function BrickPreview (line 13) | function BrickPreview() {
function Brick (line 17) | function Brick({
FILE: client/src/components/widgets/CatSwat.tsx
type CatSwatWidget (line 17) | interface CatSwatWidget extends Vector, Angled {
function CatSwatPreview (line 22) | function CatSwatPreview() {
constant CAT_REST_KEY (line 49) | const CAT_REST_KEY = imgs.indexOf(imgCatNoSwat)
constant CAT_BONK_KEY (line 50) | const CAT_BONK_KEY = imgs.indexOf(imgCatSwat)
constant MAX_CAT_SWAT_ANGLE (line 51) | const MAX_CAT_SWAT_ANGLE = Math.PI / 2.0
constant CAT_PATH (line 58) | const CAT_PATH: RandallPath[] = [
function performSwat (line 75) | function performSwat(
function CatSwat (line 139) | function CatSwat({
FILE: client/src/components/widgets/CircleGauge.tsx
constant PI_2 (line 4) | const PI_2 = 2 * Math.PI
function CircleGauge (line 6) | function CircleGauge({
FILE: client/src/components/widgets/Cup.tsx
type CupWidget (line 9) | interface CupWidget extends Vector, Angled {
function CupPreview (line 13) | function CupPreview() {
function Cup (line 17) | function Cup({ id, onSelect, x, y, angle }: CupWidget & EditableWidget) {
FILE: client/src/components/widgets/Cushion.tsx
type CushionWidget (line 9) | interface CushionWidget extends Vector, Angled {
function CushionPreview (line 13) | function CushionPreview() {
function Cushion (line 17) | function Cushion({
FILE: client/src/components/widgets/Fan.tsx
type FanWidget (line 13) | interface FanWidget extends Vector, Angled {
function FanPreview (line 19) | function FanPreview() {
function Fan (line 23) | function Fan({
FILE: client/src/components/widgets/Hammer.tsx
type HammerWidget (line 9) | interface HammerWidget extends Vector, Angled {
function HammerPreview (line 13) | function HammerPreview() {
function Hammer (line 22) | function Hammer({
FILE: client/src/components/widgets/Hook.tsx
type HookWidgetBase (line 13) | interface HookWidgetBase extends Vector, Angled {
type HookWidget (line 22) | interface HookWidget extends HookWidgetBase {
type LeftHookWidget (line 26) | interface LeftHookWidget extends HookWidgetBase {
function HookPreview (line 30) | function HookPreview() {
function FlippedHookPreview (line 39) | function FlippedHookPreview() {
function horizontalMirrorPaths (line 67) | function horizontalMirrorPaths(pathNodes: RandallPath[]): RandallPath[] {
function Hook (line 81) | function Hook({
function LeftHook (line 174) | function LeftHook(props: LeftHookWidget & EditableWidget) {
FILE: client/src/components/widgets/InputOutput.tsx
type InputOutputSide (line 38) | type InputOutputSide = 'left' | 'top' | 'right' | 'bottom'
function positionToSide (line 40) | function positionToSide(
function Triangle (line 58) | function Triangle({ className }: { className?: string }) {
function TypeIndicators (line 67) | function TypeIndicators({
function Roller (line 140) | function Roller({
function InputOutput (line 203) | function InputOutput({
FILE: client/src/components/widgets/LeftBumper.tsx
type LeftBumperWidget (line 20) | interface LeftBumperWidget extends Vector, Angled {
function LeftBumperPreview (line 26) | function LeftBumperPreview() {
function LeftBumper (line 30) | function LeftBumper({
FILE: client/src/components/widgets/MachineFrame.tsx
function Line (line 12) | function Line({
function hLinesWithBreaks (line 44) | function hLinesWithBreaks(
function vLinesWithBreaks (line 82) | function vLinesWithBreaks(
function MachineFrame (line 120) | function MachineFrame({
FILE: client/src/components/widgets/OutputValidator.tsx
constant UPDATE_MS (line 16) | const UPDATE_MS = 1000 / 15
constant RATE_WINDOW_MS (line 17) | const RATE_WINDOW_MS = 10 * 1000
function OutputValidator (line 19) | function OutputValidator({
FILE: client/src/components/widgets/Prism.tsx
type PrismWidget (line 13) | interface PrismWidget extends Vector, Angled {
function PrismPreview (line 17) | function PrismPreview() {
function pointToVectorObject (line 21) | function pointToVectorObject(
constant MIN_CONTACT_DISTANCE (line 31) | const MIN_CONTACT_DISTANCE = 0.1
constant PRISM_REFRACTION_INDEX (line 32) | const PRISM_REFRACTION_INDEX = 2.0
function setRefractionVector (line 34) | function setRefractionVector(
function Prism (line 116) | function Prism({
FILE: client/src/components/widgets/QuantumGate.tsx
type QuantumGateSlowWidget (line 13) | interface QuantumGateSlowWidget extends Vector, Sized {
type QuantumGateFastWidget (line 17) | interface QuantumGateFastWidget extends Vector, Sized {
function QuantumGateSlowPreview (line 148) | function QuantumGateSlowPreview() {
function QuantumGateFastPreview (line 166) | function QuantumGateFastPreview() {
function QuantumGate (line 183) | function QuantumGate({
function QuantumGateSlow (line 285) | function QuantumGateSlow(props: QuantumGateSlowWidget & EditableWidget) {
function QuantumGateFast (line 304) | function QuantumGateFast(props: QuantumGateFastWidget & EditableWidget) {
FILE: client/src/components/widgets/RightBumper.tsx
type RightBumperWidget (line 20) | interface RightBumperWidget extends Vector, Angled {
function RightBumperPreview (line 28) | function RightBumperPreview() {
function RightBumper (line 32) | function RightBumper({
FILE: client/src/components/widgets/RoundBumper.tsx
type RoundBumperWidget (line 24) | interface RoundBumperWidget extends Vector {
function RoundBumperPreview (line 32) | function RoundBumperPreview() {
function RoundBumper (line 36) | function RoundBumper({
FILE: client/src/components/widgets/SpawnInput.tsx
constant BALL_RATE_VARIANCE (line 14) | const BALL_RATE_VARIANCE = 1 // 50% either direction
function BallSpawner (line 16) | function BallSpawner({
function SpawnInput (line 61) | function SpawnInput({
FILE: client/src/components/widgets/Sticker.tsx
type StickerName (line 31) | type StickerName = keyof typeof stickers
type StickerWidget (line 35) | interface StickerWidget extends Vector, Angled {
function StickerPreview (line 40) | function StickerPreview({ sticker }: { sticker: StickerName }) {
function Sticker (line 54) | function Sticker({
FILE: client/src/components/widgets/Sword.tsx
type SwordWidget (line 9) | interface SwordWidget extends Vector, Angled {
function SwordPreview (line 13) | function SwordPreview() {
function Sword (line 22) | function Sword({
FILE: client/src/components/widgets/Wheel.tsx
type SpokedWheelWidget (line 15) | interface SpokedWheelWidget extends Vector {
function SpokedWheelPreview (line 20) | function SpokedWheelPreview() {
function getNextWheelSpeed (line 24) | function getNextWheelSpeed(wheel: SpokedWheelWidget, key: string) {
constant OUTER_WHEEL_RADIUS (line 44) | const OUTER_WHEEL_RADIUS = 40.0
constant WHEEL_TOP (line 45) | const WHEEL_TOP = 38
constant WHEEL_LEFT (line 46) | const WHEEL_LEFT = 79
constant NUM_SPOKES (line 47) | const NUM_SPOKES = 7
type Description (line 61) | type Description<T> = {
type HullDescription (line 64) | type HullDescription = Description<typeof upperConvexPiece>
function makeConvexHull (line 84) | function makeConvexHull(hullDesc: HullDescription, rotationAngle: number) {
function makeSpoke (line 98) | function makeSpoke(index: number, colliderDesc: typeof ColliderDesc) {
function SpokedWheel (line 129) | function SpokedWheel({
FILE: client/src/components/widgets/index.tsx
type WidgetData (line 51) | type WidgetData =
type WidgetType (line 74) | type WidgetType = WidgetData['type']
type PaletteItem (line 76) | interface PaletteItem<T> {
function Widget (line 320) | function Widget(
function WidgetsUnmemoized (line 376) | function WidgetsUnmemoized({
FILE: client/src/components/widgets/lib/ball.ts
type BallFunc (line 5) | type BallFunc = typeof ColliderDesc.ball
function Ball (line 13) | function Ball(
FILE: client/src/components/widgets/lib/lineCuboid.ts
function lineCuboid (line 5) | function lineCuboid(
FILE: client/src/custom.d.ts
type Window (line 4) | interface Window {
type Document (line 8) | interface Document {
type HTMLElement (line 12) | interface HTMLElement {
FILE: client/src/generated/api-spec.d.ts
type paths (line 7) | interface paths {
type webhooks (line 320) | type webhooks = Record<string, never>;
type components (line 322) | interface components {
type $defs (line 513) | type $defs = Record<string, never>;
type external (line 515) | type external = Record<string, never>;
type operations (line 517) | type operations = Record<string, never>;
FILE: client/src/image.d.ts
type ComicImage (line 2) | interface ComicImage {
FILE: client/src/index.tsx
type ComicGlobal (line 10) | interface ComicGlobal {}
function styleContainer (line 12) | function styleContainer(el: HTMLElement) {
function init (line 22) | function init() {
FILE: client/src/lib/coords.ts
constant M_PER_PX (line 5) | const M_PER_PX = 1 / 50
method x (line 9) | x(distance: number) {
method y (line 13) | y(distance: number) {
method length (line 17) | length(this: void, length: number) {
method lengths (line 21) | lengths<T extends number[]>(...lengths: T): T {
method vector (line 25) | vector(
method vectorObject (line 33) | vectorObject(
method angle (line 44) | angle(angle: number) {
method x (line 50) | x(distance: number) {
method y (line 54) | y(distance: number) {
method length (line 58) | length(this: void, length: number) {
method vector (line 62) | vector(x: number, y: number): [number, number] {
method angle (line 66) | angle(angle: number) {
method vector (line 72) | vector(body: RigidBody | Collider) {
method angle (line 77) | angle(body: RigidBody | Collider) {
function vectorMagnitude (line 83) | function vectorMagnitude(a: Vector) {
function vectorDistance (line 87) | function vectorDistance(a: Vector, b: Vector) {
function vectorAngle (line 91) | function vectorAngle(a: Vector, b: Vector) {
function vectorDifference (line 95) | function vectorDifference(a: Vector, b: Vector): Vector {
function vectorNorm (line 99) | function vectorNorm(a: Vector): Vector {
function vectorScale (line 105) | function vectorScale(a: Vector, scale: number): Vector {
function vectorRotate (line 109) | function vectorRotate(toRotate: Vector, angleDegrees: number): Vector {
FILE: client/src/lib/snapshot.tsx
type BodySnapshot (line 4) | interface BodySnapshot extends Vector, Angled {
type WidgetSnapshot (line 10) | interface WidgetSnapshot extends BodySnapshot {
type BallSnapshot (line 14) | interface BallSnapshot extends BodySnapshot {
type MachineSnapshot (line 19) | interface MachineSnapshot {
function snapshotBody (line 24) | function snapshotBody(body: RigidBody): BodySnapshot {
function applySnapshotToBody (line 34) | function applySnapshotToBody(
function offsetSnapshot (line 49) | function offsetSnapshot<T extends BodySnapshot>(
FILE: client/src/lib/tiles.tsx
type Grid (line 4) | type Grid<T> = T[][]
function tileBounds (line 6) | function tileBounds(
function gridDimensions (line 38) | function gridDimensions(grid: Grid<unknown>) {
function gridViewBounds (line 44) | function gridViewBounds(
function tileKey (line 56) | function tileKey(xt: number, yt: number) {
FILE: client/src/lib/utils.ts
function useIdGen (line 4) | function useIdGen(getInitial?: () => number) {
function px (line 12) | function px(value: number) {
function percent (line 16) | function percent(value: number) {
function ms (line 20) | function ms(value: number) {
function inBounds (line 24) | function inBounds(x: number, y: number, [x1, y1, x2, y2]: Bounds) {
function inBoundsOutset (line 28) | function inBoundsOutset(
function inBoundsObject (line 39) | function inBoundsObject(
function intersectBounds (line 54) | function intersectBounds(
function rotate (line 66) | function rotate(
type Basis (line 76) | interface Basis {
type RandallPath (line 82) | interface RandallPath {
FILE: client/src/page/demo-editor.tsx
function DemoEditor (line 21) | function DemoEditor() {
FILE: client/src/page/demo-map.tsx
function DemoMap (line 15) | function DemoMap({ demoPanning }: { demoPanning?: boolean }) {
FILE: client/src/page/demo-viewer.tsx
function DemoViewer (line 13) | function DemoViewer() {
FILE: client/src/types.ts
type Sized (line 7) | interface Sized {
type Angled (line 12) | interface Angled {
type OutputLoc (line 16) | interface OutputLoc {
type BallType (line 20) | type BallType = number
type BallTypeRate (line 21) | type BallTypeRate = { type: BallType; rate: number }
type PuzzlePosition (line 22) | type PuzzlePosition = { balls: BallTypeRate[] } & Vector
type Puzzle (line 24) | interface Puzzle {
type PuzzleOrder (line 29) | interface PuzzleOrder extends Puzzle {
type WidgetCollection (line 34) | type WidgetCollection = Record<string, WidgetData>
type Bounds (line 36) | type Bounds = [x1: number, y1: number, x2: number, y2: number]
type BallData (line 38) | type BallData = {
type UserData (line 44) | type UserData = BallData & { type: unknown }
type Ball (line 46) | type Ball = RigidBody & { userData: BallData }
function isBall (line 48) | function isBall(body: RigidBody): body is Ball {
FILE: client/webpack.config.js
function buildComic (line 10) | function buildComic(_env, argv) {
Condensed preview — 126 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (559K chars).
[
{
"path": ".gitignore",
"chars": 289,
"preview": "dist\ndist-*\ncabal-dev\n*.o\n*.hi\n*.hie\n*.chi\n*.chs.h\n*.dyn_o\n*.dyn_hi\n.hpc\n.hsenv\n.cabal-sandbox/\ncabal.sandbox.config\n*.p"
},
{
"path": "CHANGELOG.md",
"chars": 113,
"preview": "# Revision history for incredible\n\n## 0.1.0.0 -- YYYY-mm-dd\n\n* First version. Released on an unsuspecting world.\n"
},
{
"path": "Gen/Main.hs",
"chars": 1580,
"preview": "module Main where\n\nimport Control.Monad\nimport Incredible.App (writePuzzleMachine)\nimport "
},
{
"path": "LICENSE",
"chars": 1604,
"preview": "Code is licensed as\nCopyright (c) 2024, xkcd\n\nAll rights reserved.\n\nRedistribution and use in source and binary forms, w"
},
{
"path": "README.md",
"chars": 991,
"preview": "# Incredible\n\n[https://xkcd.com/2916](https://xkcd.com/2916)\n\n## Development\n\nThere is a Nix shell defined in `flake.nix"
},
{
"path": "api-spec.json",
"chars": 10532,
"preview": "{\"info\":{\"description\":\"Swagger API docs for Incredible\",\"title\":\"Incredible API\",\"version\":\"0\"},\"paths\":{\"/blueprint/fi"
},
{
"path": "app/Main.hs",
"chars": 2152,
"preview": "{-# LANGUAGE OverloadedStrings #-}\nmodule Main where\n\nimport Incredible.Config\nimport qualified Incredible.Dat"
},
{
"path": "client/babel.config.json",
"chars": 266,
"preview": "{\n \"presets\": [\n [\"@babel/preset-env\", { \"targets\": \"defaults\" }],\n \"@babel/preset-typescript\",\n [\n \"@bab"
},
{
"path": "client/comic.json",
"chars": 181,
"preview": "{\n \"name\": \"Incredible\",\n \"alt\": \"The Credible Machine\",\n \"url\": \"/incredible\",\n \"publicPath\": \"/\",\n \"width\": 740,\n"
},
{
"path": "client/eslint.config.js",
"chars": 1479,
"preview": "import eslint from '@eslint/js'\nimport * as reactQuery from '@tanstack/eslint-plugin-query'\nimport hooksPlugin from 'esl"
},
{
"path": "client/loaders/comic-image-loader.js",
"chars": 1557,
"preview": "import loaderUtils from 'loader-utils'\nimport sharp from 'sharp'\nimport { callbackify } from 'util'\n\nasync function proc"
},
{
"path": "client/package.json",
"chars": 2163,
"preview": "{\n \"name\": \"incredible\",\n \"version\": \"1.0.0\",\n \"description\": \"TBD\",\n \"private\": true,\n \"main\": \"src/index.tsx\",\n "
},
{
"path": "client/prettier.config.mjs",
"chars": 143,
"preview": "export default {\n trailingComma: 'all',\n tabWidth: 2,\n semi: false,\n singleQuote: true,\n plugins: ['prettier-plugin"
},
{
"path": "client/src/api.ts",
"chars": 583,
"preview": "import { QueryClient } from '@tanstack/react-query'\nimport createClient, { Middleware } from 'openapi-fetch'\nimport comi"
},
{
"path": "client/src/components/BallPitMechanism.tsx",
"chars": 5593,
"preview": "import { useCallback, useMemo, useRef } from 'react'\nimport { coords, vectorAngle } from '../lib/coords'\nimport { inBoun"
},
{
"path": "client/src/components/CenteredSlippyMap.tsx",
"chars": 4598,
"preview": "import { animate, motion, useMotionValue, useTransform } from 'framer-motion'\nimport {\n ComponentProps,\n forwardRef,\n "
},
{
"path": "client/src/components/Comic.tsx",
"chars": 6138,
"preview": "import imgEdit from '@art/edit-icon_4x.png'\nimport imgView from '@art/view-icon_4x.png'\nimport { motion } from 'framer-m"
},
{
"path": "client/src/components/ComicBrowseView.tsx",
"chars": 7549,
"preview": "import imgFollowBall from '@art/follow-ball_4x.png'\nimport imgPermalink from '@art/permalink_4x.png'\nimport { AnimatePre"
},
{
"path": "client/src/components/ComicImage.tsx",
"chars": 2537,
"preview": "import {\n HTMLAttributes,\n ImgHTMLAttributes,\n forwardRef,\n useCallback,\n useEffect,\n useRef,\n} from 'react'\nimpor"
},
{
"path": "client/src/components/ComicPuzzleView.tsx",
"chars": 3676,
"preview": "import { AnimatePresence } from 'framer-motion'\nimport { useCallback, useMemo, useRef, useState } from 'react'\nimport in"
},
{
"path": "client/src/components/DebugOverlay.tsx",
"chars": 1986,
"preview": "import { useContext, useRef } from 'react'\nimport { coords } from '../lib/coords'\nimport { MachineTileContext } from './"
},
{
"path": "client/src/components/EditorTutorials.tsx",
"chars": 3226,
"preview": "import imgTutorialCongrats from '@art/tutorial_congrats_4x.png'\nimport imgTutorialExpiry from '@art/tutorial_expiry_4x.p"
},
{
"path": "client/src/components/FullscreenComicContainer.tsx",
"chars": 3807,
"preview": "import useSize from '@react-hook/size'\nimport {\n ReactNode,\n createContext,\n useCallback,\n useContext,\n useEffect,\n"
},
{
"path": "client/src/components/InnerComicBorder.tsx",
"chars": 599,
"preview": "import { CSSProperties, ReactNode } from 'react'\nimport comic from '../../comic.json'\n\nexport default function InnerComi"
},
{
"path": "client/src/components/LoadingSpinner.tsx",
"chars": 1432,
"preview": "import { ComicImage } from './ComicImage'\n\nimport ballBlueImg from '@art/ball-blue_4x.png'\nimport ballGreenImg from '@ar"
},
{
"path": "client/src/components/MachineContext.tsx",
"chars": 7555,
"preview": "import { noop } from 'lodash'\nimport mitt, { Emitter } from 'mitt'\nimport {\n MutableRefObject,\n ReactNode,\n createCon"
},
{
"path": "client/src/components/MachineTileContext.tsx",
"chars": 6702,
"preview": "import { mapValues, noop } from 'lodash'\nimport {\n DependencyList,\n ReactNode,\n createContext,\n forwardRef,\n useCal"
},
{
"path": "client/src/components/MachineTileEditor.tsx",
"chars": 14959,
"preview": "import imgSubmit from '@art/submit_4x.png'\nimport imgWrench from '@art/wrench_4x.png'\nimport { PropsOf } from '@emotion/"
},
{
"path": "client/src/components/MachineTilePlaceholder.tsx",
"chars": 2833,
"preview": "import imgConstruction1 from '@art/construction-1_4x.png'\nimport imgConstruction2 from '@art/construction-2_4x.png'\nimpo"
},
{
"path": "client/src/components/MetaMachineView.tsx",
"chars": 6792,
"preview": "import { ClassNames } from '@emotion/react'\nimport { throttle } from 'lodash'\nimport React, {\n RefObject,\n forwardRef,"
},
{
"path": "client/src/components/NamePrompt.tsx",
"chars": 2585,
"preview": "import { css } from '@emotion/react'\nimport { sample } from 'lodash'\nimport React, { useCallback, useMemo, useState } fr"
},
{
"path": "client/src/components/PhysicsContext.tsx",
"chars": 8576,
"preview": "import type RAPIER from '@dimforge/rapier2d'\nimport {\n EventQueue,\n type Collider,\n type ColliderDesc,\n type Impulse"
},
{
"path": "client/src/components/SwooshyDialog.tsx",
"chars": 1290,
"preview": "import { css } from '@emotion/react'\nimport { motion } from 'framer-motion'\nimport React, { ReactNode, useCallback, useR"
},
{
"path": "client/src/components/WidgetPalette.tsx",
"chars": 4218,
"preview": "import imgEmergencyStop from '@art/emergency-stop_4x.png'\nimport imgTrash from '@art/trash_4x.png'\nimport { PropsOf, css"
},
{
"path": "client/src/components/constants.tsx",
"chars": 1310,
"preview": "import imgBottomPit from '@art/bottom_pit_4x.png'\nimport imgBottomChute from '@art/chute-0v_4x.png'\nimport imgBottomTank"
},
{
"path": "client/src/components/moderation/BlueprintButton.tsx",
"chars": 1933,
"preview": "import useIntersectionObserver from '@react-hook/intersection-observer'\nimport { truncate } from 'lodash'\nimport { useCa"
},
{
"path": "client/src/components/moderation/ContextGridForMachineAt.tsx",
"chars": 4918,
"preview": "import { css } from '@emotion/react'\nimport { gridDimensions, iterTiles, tileKey } from '../../lib/tiles'\nimport { inBou"
},
{
"path": "client/src/components/moderation/LiveMachinePreview.tsx",
"chars": 6631,
"preview": "import imgCheck from '@art/check-circle_4x.png'\nimport imgWrong from '@art/wrong-circle_4x.png'\nimport {\n ReactNode,\n "
},
{
"path": "client/src/components/moderation/ModMachineTileView.tsx",
"chars": 1596,
"preview": "import { Puzzle, WidgetCollection } from '../../types'\nimport { Widgets } from '../widgets'\nimport { Balls } from '../wi"
},
{
"path": "client/src/components/moderation/ModTileInputOutputView.tsx",
"chars": 1369,
"preview": "import { css } from '@emotion/react'\nimport { percent } from '../../lib/utils'\nimport { Puzzle, PuzzlePosition } from '."
},
{
"path": "client/src/components/moderation/Moderator.tsx",
"chars": 8442,
"preview": "import { Global } from '@emotion/react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { clamp "
},
{
"path": "client/src/components/moderation/SelectTileForm.tsx",
"chars": 1189,
"preview": "import React, { useCallback } from 'react'\n\nexport default function SelectTileForm({\n xt,\n yt,\n onGotoLoc,\n onNextEm"
},
{
"path": "client/src/components/moderation/interestingWeights.ts",
"chars": 390,
"preview": "import { WidgetType } from '../widgets'\n\nexport const interestingWeights: Record<WidgetType, number> = {\n brick: 1,\n a"
},
{
"path": "client/src/components/moderation/modTypes.d.ts",
"chars": 348,
"preview": "import { components } from '../../generated/api-spec'\n\nexport type ModLocation = {\n puzzle: string\n blueprint?: string"
},
{
"path": "client/src/components/moderation/modUtils.ts",
"chars": 2519,
"preview": "import { random, sample, sortBy } from 'lodash'\nimport { gridDimensions, iterTiles } from '../../lib/tiles'\nimport { int"
},
{
"path": "client/src/components/moderation/moderatorClient.ts",
"chars": 3672,
"preview": "import {\n queryOptions,\n useMutation,\n useQueries,\n useQuery,\n} from '@tanstack/react-query'\nimport { apiClient, que"
},
{
"path": "client/src/components/positionStyles.ts",
"chars": 2151,
"preview": "import type { RigidBody } from '@dimforge/rapier2d'\nimport {\n MutableRefObject,\n RefObject,\n useCallback,\n useEffect"
},
{
"path": "client/src/components/useLocationHashParams.tsx",
"chars": 1356,
"preview": "import { useCallback, useEffect, useMemo, useState } from 'react'\n\nexport function paramToNumber(text: string | null | u"
},
{
"path": "client/src/components/useMetaMachineClient.ts",
"chars": 6572,
"preview": "import {\n queryOptions,\n useMutation,\n useQueries,\n useQuery,\n} from '@tanstack/react-query'\nimport { dropRightWhile"
},
{
"path": "client/src/components/widgets/Anvil.tsx",
"chars": 1918,
"preview": "import imgAnvil from '@art/anvil_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../.."
},
{
"path": "client/src/components/widgets/AttractorRepulsor.tsx",
"chars": 4091,
"preview": "import imgAttractor from '@art/attractor_4x.png'\nimport imgRepulsor from '@art/repulsor_4x.png'\nimport { useState } from"
},
{
"path": "client/src/components/widgets/BallStand.tsx",
"chars": 2333,
"preview": "import imgBallStand from '@art/ball-stand_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } fr"
},
{
"path": "client/src/components/widgets/Balls.tsx",
"chars": 8159,
"preview": "import ballBlueImg from '@art/ball-blue_4x.png'\nimport ballGreenImg from '@art/ball-green_4x.png'\nimport ballRedImg from"
},
{
"path": "client/src/components/widgets/Board.tsx",
"chars": 1195,
"preview": "import imgBoard from '@art/board_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../.."
},
{
"path": "client/src/components/widgets/Boat.tsx",
"chars": 2507,
"preview": "import boatImage from '@art/boat_4x.png'\nimport { type Vector } from '@dimforge/rapier2d'\nimport { coords } from '../../"
},
{
"path": "client/src/components/widgets/BottomChute.tsx",
"chars": 2215,
"preview": "import imgBottomChute from '@art/chute-0v_4x.png'\nimport imgBottomChute25L from '@art/chute-25l_4x.png'\nimport imgBottom"
},
{
"path": "client/src/components/widgets/BottomPit.tsx",
"chars": 1860,
"preview": "import imgBottomPit from '@art/bottom_pit_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Vector } from '../."
},
{
"path": "client/src/components/widgets/BottomTank.tsx",
"chars": 4598,
"preview": "import tankBlueOpenImg from '@art/tank-blue-open_4x.png'\nimport tankBlueImg from '@art/tank-blue_4x.png'\nimport tankGree"
},
{
"path": "client/src/components/widgets/Brick.tsx",
"chars": 1140,
"preview": "import imgBrick from '@art/brick_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../.."
},
{
"path": "client/src/components/widgets/CatSwat.tsx",
"chars": 8280,
"preview": "import imgCatNoSwat from '@art/cat-noswat_4x.png'\nimport imgCatSwat from '@art/cat-swat_4x.png'\nimport type { Collider }"
},
{
"path": "client/src/components/widgets/CircleGauge.tsx",
"chars": 923,
"preview": "import { clamp } from 'lodash'\nimport { SVGAttributes } from 'react'\n\nconst PI_2 = 2 * Math.PI\n\nexport function CircleGa"
},
{
"path": "client/src/components/widgets/Cup.tsx",
"chars": 2051,
"preview": "import imgCup from '@art/cup_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../../typ"
},
{
"path": "client/src/components/widgets/Cushion.tsx",
"chars": 2233,
"preview": "import imgCushion from '@art/cushion_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '."
},
{
"path": "client/src/components/widgets/Fan.tsx",
"chars": 4270,
"preview": "import img1 from '@art/fan-blade1_4x.png'\nimport img2 from '@art/fan-blade2_4x.png'\nimport img3 from '@art/fan-blade3_4x"
},
{
"path": "client/src/components/widgets/Hammer.tsx",
"chars": 2251,
"preview": "import imgHammer from '@art/hammer_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../"
},
{
"path": "client/src/components/widgets/Hook.tsx",
"chars": 4557,
"preview": "import { coords } from '../../lib/coords'\nimport { RandallPath } from '../../lib/utils'\nimport { Angled, Vector } from '"
},
{
"path": "client/src/components/widgets/InputOutput.tsx",
"chars": 7188,
"preview": "import imgRoller1 from '@art/one-roller-1_4x.png'\nimport imgRoller2 from '@art/one-roller-2_4x.png'\nimport imgRoller3 fr"
},
{
"path": "client/src/components/widgets/LeftBumper.tsx",
"chars": 4470,
"preview": "import imgBonkOff from '@art/tribonker-off_4x.png'\nimport imgBonkOn from '@art/tribonker-on_4x.png'\nimport { useState } "
},
{
"path": "client/src/components/widgets/MachineFrame.tsx",
"chars": 5735,
"preview": "import { groupBy, sortBy } from 'lodash'\nimport { useCallback, useRef } from 'react'\nimport { coords } from '../../lib/c"
},
{
"path": "client/src/components/widgets/OutputValidator.tsx",
"chars": 3420,
"preview": "import { useCallback, useEffect, useMemo, useState } from 'react'\nimport { BallData, PuzzlePosition } from '../../types'"
},
{
"path": "client/src/components/widgets/Prism.tsx",
"chars": 5802,
"preview": "import { useCallback } from 'react'\nimport { coords } from '../../lib/coords'\nimport { Angled, Ball, Vector, isBall } fr"
},
{
"path": "client/src/components/widgets/QuantumGate.tsx",
"chars": 6821,
"preview": "/* eslint-disable @typescript-eslint/no-unsafe-member-access */\n/* eslint-disable @typescript-eslint/no-unsafe-argument "
},
{
"path": "client/src/components/widgets/RightBumper.tsx",
"chars": 4447,
"preview": "import imgBonkOff from '@art/mirrortribonker-off_4x.png'\nimport imgBonkOn from '@art/mirrortribonker-on_4x.png'\nimport {"
},
{
"path": "client/src/components/widgets/RoundBumper.tsx",
"chars": 3793,
"preview": "import imgBonkOff from '@art/bonker-off_4x.png'\nimport imgBonkOn from '@art/bonker-on_4x.png'\nimport { useState } from '"
},
{
"path": "client/src/components/widgets/SpawnInput.tsx",
"chars": 2345,
"preview": "import { sumBy } from 'lodash'\nimport { useCallback, useRef } from 'react'\nimport weighted from 'weighted'\nimport { Puzz"
},
{
"path": "client/src/components/widgets/Sticker.tsx",
"chars": 1915,
"preview": "import imgCat from '@art/cat_4x.png'\nimport imgDeterminism from '@art/determinism-sign_4x.png'\nimport imgFigure1Happy fr"
},
{
"path": "client/src/components/widgets/Sword.tsx",
"chars": 2320,
"preview": "import imgSword from '@art/sword_4x.png'\nimport { coords } from '../../lib/coords'\nimport { Angled, Vector } from '../.."
},
{
"path": "client/src/components/widgets/Wheel.tsx",
"chars": 5518,
"preview": "import { coords } from '../../lib/coords'\nimport { Vector } from '../../types'\nimport { ComicImage } from '../ComicImage"
},
{
"path": "client/src/components/widgets/index.tsx",
"chars": 9308,
"preview": "import assertNever from 'assert-never'\nimport { mapValues } from 'lodash'\nimport React, { FC } from 'react'\nimport { Wid"
},
{
"path": "client/src/components/widgets/lib/ball.ts",
"chars": 831,
"preview": "import { ColliderDesc } from '@dimforge/rapier2d'\nimport { coords } from '../../../lib/coords'\nimport { Basis } from '.."
},
{
"path": "client/src/components/widgets/lib/lineCuboid.ts",
"chars": 723,
"preview": "import type { ColliderDesc } from '@dimforge/rapier2d'\nimport { coords } from '../../../lib/coords'\nimport { Basis, Rand"
},
{
"path": "client/src/custom.d.ts",
"chars": 296,
"preview": "import type { ComicGlobal } from '.'\n\ndeclare global {\n interface Window {\n Comic: ComicGlobal\n }\n\n interface Docu"
},
{
"path": "client/src/generated/api-spec.d.ts",
"chars": 11638,
"preview": "/**\n * This file was auto-generated by openapi-typescript.\n * Do not make direct changes to the file.\n */\n\n\nexport inter"
},
{
"path": "client/src/image.d.ts",
"chars": 220,
"preview": "declare module '*.png' {\n interface ComicImage {\n width: number\n height: number\n url: {\n '4x': string\n "
},
{
"path": "client/src/index.ejs",
"chars": 518,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <title><%= comic.name %></title>\n <style>\n"
},
{
"path": "client/src/index.tsx",
"chars": 831,
"preview": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\n\nimport { QueryClientProvider } from '@"
},
{
"path": "client/src/lib/coords.ts",
"chars": 2701,
"preview": "import type { Collider, RigidBody } from '@dimforge/rapier2d'\nimport type { Vector } from '../types'\nimport { Basis } fr"
},
{
"path": "client/src/lib/snapshot.tsx",
"chars": 1206,
"preview": "import type { RigidBody, Vector } from '@dimforge/rapier2d'\nimport { Angled, BallType } from '../types'\n\nexport interfac"
},
{
"path": "client/src/lib/tiles.tsx",
"chars": 1381,
"preview": "import { Bounds } from '../types'\nimport { intersectBounds } from './utils'\n\nexport type Grid<T> = T[][]\n\nexport functio"
},
{
"path": "client/src/lib/utils.ts",
"chars": 1797,
"preview": "import { useCallback, useRef } from 'react'\nimport { Bounds } from '../types'\n\nexport function useIdGen(getInitial?: () "
},
{
"path": "client/src/page/demo-editor.tsx",
"chars": 2311,
"preview": "import { StrictMode, useRef, useState } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport comic from '.."
},
{
"path": "client/src/page/demo-map.tsx",
"chars": 1495,
"preview": "import { StrictMode, useEffect, useRef } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { SlippyMapRe"
},
{
"path": "client/src/page/demo-viewer.tsx",
"chars": 1597,
"preview": "import { QueryClientProvider } from '@tanstack/react-query'\nimport { StrictMode, useState } from 'react'\nimport { create"
},
{
"path": "client/src/page/fixtures/demoMachine.tsx",
"chars": 5201,
"preview": "import { MachineSnapshot } from '../../lib/snapshot'\nimport { WidgetCollection } from '../../types'\n\nexport const demoWi"
},
{
"path": "client/src/page/fixtures/emptyMachine.tsx",
"chars": 608,
"preview": "import { Puzzle, WidgetCollection } from '../../types'\n\nexport const emptyPuzzle: Puzzle = {\n inputs: [\n { x: 0.5, y"
},
{
"path": "client/src/page/moderator.tsx",
"chars": 445,
"preview": "import { QueryClientProvider } from '@tanstack/react-query'\nimport { StrictMode } from 'react'\nimport { createRoot } fro"
},
{
"path": "client/src/page/page.ejs",
"chars": 392,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <title><%= comic.name %></title>\n <style>\n"
},
{
"path": "client/src/types.ts",
"chars": 1261,
"preview": "import type { RigidBody, Vector } from '@dimforge/rapier2d'\nimport { isNumber, isString } from 'lodash'\nimport { WidgetD"
},
{
"path": "client/tsconfig.json",
"chars": 420,
"preview": "{\n \"compilerOptions\": {\n \"sourceMap\": true,\n \"noImplicitAny\": true,\n \"esModuleInterop\": true,\n \"module\": \"e"
},
{
"path": "client/webpack.config.js",
"chars": 3007,
"preview": "import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'\nimport HtmlWebpackPlugin from 'html-webpack"
},
{
"path": "config/incredible.toml",
"chars": 567,
"preview": "[web]\nport = 8888\nbase_url = \"/\"\norigins = [\"http://localhost\", \"http://127.0.0.1\", \"http://localhost:8889\", \"http://loc"
},
{
"path": "config/machine.json",
"chars": 12737,
"preview": "{\"tile_size\":{\"x\":740,\"y\":740},\"ms_per_ball\":1000.0,\"prio_puzzles\":[],\"grid\":[[{\"reqTiles\":[],\"inputs\":[{\"x\":0.65,\"y\":0."
},
{
"path": "docs/Main.hs",
"chars": 19224,
"preview": "{-# LANGUAGE DataKinds #-}\n{-# LANGUAGE DerivingVia #-}\n{-# LANGUAGE GADTs #-}\n{-# LANGUAGE FlexibleInstances #-}\n{-# LA"
},
{
"path": "flake.nix",
"chars": 4371,
"preview": "{\n description = \"incredible\";\n\n inputs.nixpkgs.follows = \"haskellNix/nixpkgs-unstable\";\n inputs.nixpkgs-unstable.url"
},
{
"path": "incredible.cabal",
"chars": 2613,
"preview": "cabal-version: 3.0\nname: incredible\nversion: 0\n-- synopsis:\n-- description:\nlicense: "
},
{
"path": "nix/deploy.nix",
"chars": 2633,
"preview": "{ pkgs }:\npkgs.writeShellScriptBin \"deployScript\" ''\n #!/usr/bin/env bash\n # This script is used to deploy NixOS c"
},
{
"path": "nix/digital-ocean/digital-ocean-config.nix",
"chars": 7213,
"preview": "{ config, pkgs, lib, modulesPath, ... }:\nwith lib;\n{\n imports = [\n (modulesPath + \"/profiles/qemu-guest.nix\")\n (m"
},
{
"path": "nix/digital-ocean/digital-ocean-custom-image.nix",
"chars": 2152,
"preview": "{ config, lib, pkgs, ... }:\n\nwith lib;\nlet\n cfg = config.virtualisation.digitalOceanImage;\nin\n{\n\n imports = [ ./digita"
},
{
"path": "nix/digital-ocean/make-single-disk-zfs-image.nix",
"chars": 9190,
"preview": "# Note: This is a private API, internal to NixOS. Its interface is subject\n# to change without notice.\n#\n# The result of"
},
{
"path": "nix/incredible-cfg.nix",
"chars": 2242,
"preview": "{ config, pkgs, ... }:\n{\n imports = [\n ./users/deploy.nix\n ];\n\n system.stateVersion = \"22.05\";\n\n networking.hostI"
},
{
"path": "nix/incredible-digital-ocean.nix",
"chars": 604,
"preview": "{ config, pkgs, ... }:\n{\n imports = [\n ./digital-ocean/digital-ocean-custom-image.nix\n ];\n\n virtualisation = {\n "
},
{
"path": "nix/incredible-frontend.nix",
"chars": 1004,
"preview": "incredible-frontend: { config, pkgs, lib, ... }:\nwith lib;\nlet cfg = config.services.incredible-frontend; in\n{\n options"
},
{
"path": "nix/incredible-qemu.nix",
"chars": 548,
"preview": "{ config, modulesPath, pkgs, lib,... }:\n{\n imports = [\n \"${modulesPath}/profiles/qemu-guest.nix\"\n \"${modulesPath}"
},
{
"path": "nix/incredible-server.nix",
"chars": 1447,
"preview": "incredible-server: { config, pkgs, lib, ... }:\n\nwith lib;\nlet cfg = config.services.incredible-server; in\n{\n options = "
},
{
"path": "nix/profiles/staging.nix",
"chars": 234,
"preview": "{ config, lib, pkgs, ... }:\n\n{\n services.incredible-server = {\n enable = true;\n config = ../../config/incredible."
},
{
"path": "nix/users/deploy.nix",
"chars": 247,
"preview": "{ pkgs, ... }:\n{\n programs.zsh.enable = true;\n users.users.deploy = {\n isNormalUser = true;\n extraGroups = [ \""
},
{
"path": "src/Incredible/API.hs",
"chars": 13869,
"preview": "{-# LANGUAGE DataKinds #-}\n{-# LANGUAGE FlexibleContexts #-}\n{-# LANGUAGE FlexibleInstances #-}\n{-# LANGUAGE Generalized"
},
{
"path": "src/Incredible/AntiEvil.hs",
"chars": 643,
"preview": "{-# LANGUAGE FlexibleContexts #-}\n{-# LANGUAGE OverloadedStrings #-}\n-- | Certainly not good.\nmodule Incredible.AntiEvil"
},
{
"path": "src/Incredible/App.hs",
"chars": 6106,
"preview": "{-# LANGUAGE FlexibleContexts #-}\n{-# LANGUAGE OverloadedStrings #-}\n{-# LANGUAGE ScopedTypeVariables #-}\nmodule Incredi"
},
{
"path": "src/Incredible/Config.hs",
"chars": 4859,
"preview": "{-# LANGUAGE ImportQualifiedPost #-}\n{-# LANGUAGE OverloadedStrings #-}\n{-# LANGUAGE ScopedTypeVariables #-}\n{-# LANGUAG"
},
{
"path": "src/Incredible/Data.hs",
"chars": 24227,
"preview": "{-# LANGUAGE DeriveAnyClass #-}\n{-# LANGUAGE DeriveTraversable #-}\n{-# LANGUAGE FlexibleContexts #-}\n{-# LANGUAGE Flexib"
},
{
"path": "src/Incredible/DataStore/Memory.hs",
"chars": 4504,
"preview": "{-# language ImportQualifiedPost #-}\n{-# LANGUAGE TypeFamilies #-}\nmodule Incredible.DataStore.Memory where\n\nimport Cont"
},
{
"path": "src/Incredible/DataStore/Redis.hs",
"chars": 12918,
"preview": "{-# language ImportQualifiedPost #-}\n{-# LANGUAGE LambdaCase #-}\n{-# LANGUAGE OverloadedStrings #-}\n{-# LANGUAGE RankNTy"
},
{
"path": "src/Incredible/DataStore.hs",
"chars": 6506,
"preview": "{-# LANGUAGE AllowAmbiguousTypes #-}\n{-# LANGUAGE FlexibleContexts #-}\n{-# LANGUAGE FlexibleInstances #-}\n{-# LANGUAGE F"
},
{
"path": "src/Incredible/Puzzle.hs",
"chars": 42369,
"preview": "{-# LANGUAGE BangPatterns #-}\n{-# LANGUAGE LambdaCase #-}\n{-# LANGUAGE RankNTypes #-}\n{-# LANGU"
},
{
"path": "test/Main.hs",
"chars": 23345,
"preview": "{-# LANGUAGE FlexibleInstances #-}\n{-# LANGUAGE OverloadedLists #-}\n{-# LANGUAGE MultiParamTypeClasses #-}\n{-# LANGUAGE "
}
]
About this extraction
This page contains the full source code of the xkcd/incredible GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 126 files (515.2 KB), approximately 146.2k tokens, and a symbol index with 346 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.