├── .github
├── ISSUE_TEMPLATE
│ └── feature_request.md
└── workflows
│ ├── deploy.yml
│ └── sync-submodules.yml
├── .gitignore
├── .gitmodules
├── .vscode
├── extensions.json
├── launch.json
├── settings.json
└── snippets.code-snippets
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── assets.json
├── dprint.json
├── index.html
├── kaplayground.png
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── public
└── pg.png
├── sandbox
├── index.html
├── vite.config.ts
└── wrangler.jsonc
├── scripts
├── examples.ts
└── types.ts
├── src
├── App.tsx
├── components
│ ├── About
│ │ ├── AboutDialog.tsx
│ │ └── index.ts
│ ├── AssetBrew
│ │ ├── AssetBrew.tsx
│ │ └── AssetBrewItem.tsx
│ ├── Assets
│ │ ├── Assets.tsx
│ │ ├── AssetsAddButton.tsx
│ │ ├── AssetsItem.tsx
│ │ ├── AssetsList.tsx
│ │ ├── AssetsPanel.css
│ │ ├── AssetsPanel.tsx
│ │ ├── AssetsTab.tsx
│ │ └── index.ts
│ ├── Config
│ │ ├── ConfigDialog.tsx
│ │ ├── ConfigEditor.tsx
│ │ └── ConfigForm
│ │ │ ├── ConfigCheckbox.tsx
│ │ │ └── ConfigSelect.tsx
│ ├── ConsoleView
│ │ └── ConsoleView.tsx
│ ├── Editor
│ │ ├── MonacoEditor.tsx
│ │ ├── actions
│ │ │ └── format.ts
│ │ ├── completion
│ │ │ └── KAPLAYSnippets.ts
│ │ ├── completionProviders.ts
│ │ ├── monacoConfig.ts
│ │ ├── snippets
│ │ │ └── compSnippets.ts
│ │ └── themes
│ │ │ └── themes.ts
│ ├── FileTree
│ │ ├── FileEntry.css
│ │ ├── FileEntry.tsx
│ │ ├── FileFold.tsx
│ │ ├── FileFolder.css
│ │ ├── FileToolbar.tsx
│ │ ├── FileTree.tsx
│ │ └── index.ts
│ ├── Playground
│ │ ├── GameView.tsx
│ │ ├── LoadingPlayground.tsx
│ │ ├── Playground.tsx
│ │ ├── WorkspaceExample.tsx
│ │ └── WorkspaceProject.tsx
│ ├── ProjectBrowser
│ │ ├── GroupBy.tsx
│ │ ├── ProjectBrowser.css
│ │ ├── ProjectBrowser.tsx
│ │ ├── ProjectCreate.tsx
│ │ ├── ProjectEntry.tsx
│ │ ├── SortBy.tsx
│ │ ├── TagsFilter.tsx
│ │ └── index.ts
│ ├── Toolbar
│ │ ├── ExampleList.tsx
│ │ ├── ProjectStatus.tsx
│ │ ├── ToolButtons
│ │ │ ├── AboutButton.tsx
│ │ │ ├── ConfigButton.tsx
│ │ │ └── ShareButton.tsx
│ │ ├── Toolbar.tsx
│ │ ├── ToolbarButton.tsx
│ │ ├── ToolbarDropdown.tsx
│ │ ├── ToolbarDropdownButton.tsx
│ │ ├── ToolbarProjectDropdown.tsx
│ │ ├── ToolbarToolsMenu.tsx
│ │ └── index.ts
│ └── UI
│ │ ├── Dialog.tsx
│ │ ├── KDropdown
│ │ └── KDropdownSeparator.tsx
│ │ ├── TabTrigger.tsx
│ │ ├── TabsList.tsx
│ │ └── View.tsx
├── config
│ ├── common.ts
│ └── defaultProject.ts
├── data
│ ├── demos.ts
│ └── exampleList.json
├── features
│ ├── Editor
│ │ └── application
│ │ │ └── insertAfterCursor.ts
│ └── Projects
│ │ ├── application
│ │ ├── buildCode.ts
│ │ ├── buildProject.ts
│ │ ├── wrapCode.ts
│ │ └── wrapGame.ts
│ │ ├── models
│ │ ├── Asset.ts
│ │ ├── AssetKind.ts
│ │ ├── File.ts
│ │ ├── FileFolder.ts
│ │ ├── FileKind.ts
│ │ ├── Project.ts
│ │ ├── ProjectMode.ts
│ │ └── UploadAsset.ts
│ │ └── stores
│ │ ├── slices
│ │ ├── assets.ts
│ │ ├── files.ts
│ │ └── project.ts
│ │ └── useProject.ts
├── hooks
│ ├── useAssets.ts
│ ├── useConfig.ts
│ └── useEditor.ts
├── main.tsx
├── styles
│ ├── index.css
│ └── toast.css
├── util
│ ├── allotmentStorage.ts
│ ├── assetsParsing.ts
│ ├── cn.ts
│ ├── compiler.ts
│ ├── compressCode.ts
│ ├── download.ts
│ ├── fileToBase64.ts
│ ├── logs.ts
│ ├── npm.ts
│ ├── regex.ts
│ ├── removeExtensions.ts
│ ├── scrollbarSize.ts
│ ├── stringToValue.ts
│ └── types.ts
└── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: feature
6 | assignees: ''
7 | type: 'Feature'
8 | ---
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | workflow_run:
4 | workflows: ["Sync Submodules"]
5 | types:
6 | - completed
7 |
8 |
9 | jobs:
10 | deploy:
11 | name: "Deploy website on Cloudflare"
12 | runs-on: ubuntu-latest
13 | if: github.repository_owner == 'kaplayjs'
14 | steps:
15 | - name: Checkout repository
16 | uses: actions/checkout@v4
17 | with:
18 | submodules: true
19 | fetch-depth: 0
20 | - name: Setup pnpm
21 | uses: pnpm/action-setup@v4
22 | - name: Setup Node.js
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: "22"
26 | cache: "pnpm"
27 | - name: Install dependencies
28 | run: pnpm install
29 | - name: Build app
30 | run: pnpm build
31 | - name: Deploy app
32 | uses: cloudflare/wrangler-action@v3
33 | with:
34 | packageManager: pnpm
35 | apiToken: ${{ secrets.CLOUDFLARE_WORKERS_KEY }}
36 | command: pages deploy dist --project-name=kaplay
37 |
--------------------------------------------------------------------------------
/.github/workflows/sync-submodules.yml:
--------------------------------------------------------------------------------
1 | # This Action is dispatched by kaplayjs/kaplay on master push
2 |
3 | name: "Sync Submodules"
4 |
5 | on:
6 | workflow_dispatch:
7 |
8 | jobs:
9 | sync:
10 | permissions: write-all
11 | name: "Sync Submodules"
12 | runs-on: ubuntu-latest
13 |
14 | defaults:
15 | run:
16 | shell: bash
17 |
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v4
21 | with:
22 | submodules: true
23 | - name: Git Submodule Update
24 | run: |
25 | git pull --recurse-submodules
26 | git submodule update --remote --recursive
27 | - name: Commit and Push Changes
28 | env:
29 | BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
30 | run: |
31 | git config --global user.name "Bag Bot"
32 | git config --global user.email "lajbel@kaplayjs.com"
33 | git add .
34 | git commit -m "chore: bump repo" || echo "No changes to commit"
35 | git push https://x-access-token:$BOT_TOKEN@github.com/${{ github.repository }}.git
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 |
4 | # generated types
5 | .astro/
6 |
7 | # dependencies
8 | node_modules/
9 |
10 | # logs
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # environment variables
17 | .env
18 | .env.production
19 |
20 | # macOS-specific files
21 | .DS_Store
22 |
23 | # jetbrains setting folder
24 | .idea/
25 |
26 | .wrangler
27 | public/kaboom.js
28 | public/kaboom.js.map
29 | lib.d.ts
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "kaplay"]
2 | path = kaplay
3 | url = https://github.com/marklovers/kaplay
4 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["astro-build.astro-vscode"],
3 | "unwantedRecommendations": []
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "command": "./node_modules/.bin/astro dev",
6 | "name": "Development server",
7 | "request": "launch",
8 | "type": "node-terminal"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.preferences.importModuleSpecifier": "relative",
3 | "typescript.preferences.importModuleSpecifierEnding": "minimal"
4 | }
--------------------------------------------------------------------------------
/.vscode/snippets.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "Print to console": {
3 | "scope": "javascript,typescript",
4 | "prefix": "ie",
5 | "body": [
6 | "import ${1}Example from \"./../../../../kaplay/examples/${1}.js?raw\";",
7 | ],
8 | "description": "Import an example"
9 | }
10 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [2.3.3] - 2025-05-16
9 |
10 | ### Fixed
11 |
12 | - Fixed the /crew asset parsing not working properly - @lajbel
13 | - Fixed ?example= from url when saving a project - @lajbel
14 |
15 | ## [2.3.2] - 2025-05-16
16 |
17 | ### Added
18 |
19 | - Now when opening demos, the url is updated to the demo url
20 |
21 | ### Fixed
22 |
23 | - Fixed visual bugs in the editor - @imaginarny
24 | - Fixed demo links not using ?example=[exampleName] - @lajbel
25 |
26 | ## [2.3.1] - 2025-05-14
27 |
28 | ### Fixed
29 |
30 | - Fixed bugs in the editor view - @imaginarny
31 | - Fixed a bug where some configurations wasn't being updated - @lajbel
32 |
33 | ## [2.3.0] - 2025-05-12
34 |
35 | ### Added
36 |
37 | - Now file state (cursor position, selected, scroll, undo/redo) is saved and preserved
38 | when switching between files
39 | - Better UI in Project Dropdown in Toolbar
40 |
41 | ### Changed
42 |
43 | - `Project.id` is depracted inside `.kaplay` files, you can safely remove it
44 |
45 | ### Fixed
46 |
47 | - Fixed bugs in the editor
48 | - Rendering Optimization
49 | - Bean added to Project Core
50 |
51 | ## [2.2.2] - 2025-05-5
52 |
53 | ### Fixed
54 |
55 | - Fixed bugs in the editor
56 |
57 | ## [2.2.1] - 2025-05-5
58 |
59 | ### Fixed
60 |
61 | - Fixed bugs in the editor
62 |
63 | ## [2.2.0] - 2025-04-26
64 |
65 | ### Added
66 |
67 | - Group projects and examples by category, topic or difficulty
68 | - Sort projects and examples by group options, type or latest
69 | - Filter projects and examples by tags
70 | - New and Updated labels added to examples
71 |
72 | ## [2.1.1] - 2025-04-15
73 |
74 | ### Added
75 |
76 | - New KAPLAYGROUND logo
77 | - Console log pane added
78 | - Asset brew pane added to Example workspace
79 | - Project now auto-saves when renamed
80 | - User-friendly empty state screen for My Projects in Projects Browser added
81 | - Editor word wrap toggling as a command and config option
82 |
83 | ### Changed
84 |
85 | - Overal design updated to match the website redesign and rebrand
86 | - Editor now has a custom color theme
87 | - Resized pane sizes are now remembered after a page reload
88 | - Current project is highlighted and synced across Projects Browser and select
89 | - Project reruns on version change
90 |
91 | ### Fixed
92 |
93 | - Project naming and filtering by project type in the Projects select
94 |
95 | ## [2.1.0] - 2025-03-25
96 |
97 | ### Added
98 |
99 | - Now share links have version
100 | - Now you can select `master` version for using latest commit
101 |
102 | ### Changed
103 |
104 | - First part of redesign making style be more similar to website
105 |
106 | ## [2.0.2] - 2025-01-15
107 |
108 | ### Changed
109 |
110 | - Now version selector doesn't lie about the version! - @lajbel
111 |
112 | ## 2.0.1 (3/11/2024)
113 |
114 | - Renamed examples to demos
115 |
116 | ## 2.0.0 (31/10/2024) Spooky Edition
117 |
118 | - todo()
119 |
120 | ## 1.0.0-beta (27/5/2024)
121 |
122 | Project renamed to **KAPLAYGROUND**, due to the name **KAPLAY** is now used
123 | by [KAPLAY](https://kaplayjs.com), a game engine.
124 |
125 | ### Features
126 |
127 | - ⭐ Multi-file editing support (with scenes)
128 | - ⭐ KAPLAY examples support
129 | - ⭐ Tooltip, toasts and much feedback!
130 | - ⭐ Now Kaboom configuration is supported in the editor (no in share links/examples)
131 | - added fonts support
132 | - added a loading screen
133 | - added reset project option
134 | - now editor is snapable at all
135 |
136 | ### Misc
137 |
138 | - new logo, hi dino!
139 | - now kaplay is used instead kaboom
140 |
141 | ## 0.1.1 (9/5/2024)
142 |
143 | - fixed code url not loading
144 |
145 | ## 0.1.0 (9/5/2024)
146 |
147 | The initial release!
148 |
149 | ### Features
150 |
151 | - added project import and export
152 | - added panes resizing
153 | - now assets dragging in editor put the asset in a new line
154 |
155 | ### Bug Fixes
156 |
157 | - fixed duplied assets
158 | - fixed (doubtful) a bug of editor writing
159 |
160 | ### Misc
161 |
162 | - added about the project window
163 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to KAPLAYGROUND
2 |
3 | We are currently working on `new-editor` branch, please don't make any changes
4 | to `master` branch.
5 |
6 | ## Setup environment
7 |
8 | ```
9 | git clone https://github.com/kaplayjs/kaplayground.git
10 | cd kaplayground
11 | pnpm i # will install and setup stuff of submodules
12 | pnpm dev # will start the development server
13 | pnpm fmt # before commit
14 | ```
15 |
16 | ## Commit messages
17 |
18 | Follow the KAPLAY repo [conventional commits guidelines.](https://github.com/kaplayjs/kaplay/blob/master/CONTRIBUTING.md#conventional-commits-guide)
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 KAPLAY Team
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🧰 KAPLAYGROUND — Web Editor for KAPLAY Games
2 |
3 |
4 |

5 |
6 |
7 | ---
8 |
9 | **KAPLAYGROUND** is a powerful, web-based editor designed specifically for creating, editing, and sharing KAPLAY game projects — all from your browser.
10 |
11 | ## 🚀 Features
12 |
13 | - 🎯 **Multi-file Editing**\
14 | Work on full projects with **multiple files** or quickly prototype with a **single script**.
15 |
16 | - 📂 **Project Browser**\
17 | Open and load KAPLAY projects directly from your local machine.
18 |
19 | - 🍺 **Asset Brew**\
20 | Quickly import a curated set of default assets to kickstart your projects or examples.
21 |
22 | - 🌲 **File Tree Navigation**\
23 | Browse your project structure with an intuitive file tree sidebar.
24 |
25 | - 🛠️ **Code Editor**\
26 | Use a modern, powerful code editor based in VS Code, with features like auto-completion, syntax highlighting and special KAPLAY snippets and autocompletion.
27 |
28 | ## 📚 Resources
29 |
30 | - [Roadmap](https://github.com/orgs/kaplayjs/projects/14/views/1) -
31 | See what features are planned for the future.
32 | - [KAPLAYGROUND Wiki](https://github.com/kaplayjs/kaplayground/wiki) -
33 | Explore the wiki for in-depth guides, tutorials, and documentation.
34 |
--------------------------------------------------------------------------------
/dprint.json:
--------------------------------------------------------------------------------
1 | {
2 | "indentWidth": 4,
3 | "lineWidth": 80,
4 | "typescript": {
5 | },
6 | "json": {
7 | "indentWidth": 2
8 | },
9 | "markdown": {
10 | },
11 | "excludes": [
12 | "**/node_modules",
13 | "**/*-lock.json"
14 | ],
15 | "markup": {
16 | },
17 | "plugins": [
18 | "https://plugins.dprint.dev/typescript-0.90.4.wasm",
19 | "https://plugins.dprint.dev/json-0.19.2.wasm",
20 | "https://plugins.dprint.dev/markdown-0.17.0.wasm",
21 | "https://plugins.dprint.dev/g-plane/markup_fmt-v0.7.0.wasm"
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | KAPLAYGROUND, a playground for making JavaScript games
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/kaplayground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaplayjs/kaplayground/6ebafc1b39d510fc5bd73e516555604c157c18ae/kaplayground.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "kaplayground",
3 | "type": "module",
4 | "version": "2.3.2",
5 | "bin": "scripts/cli.js",
6 | "scripts": {
7 | "dev": "vite dev",
8 | "start": "vite dev",
9 | "build": "vite build",
10 | "preview": "vite preview",
11 | "generate:examples": "node --experimental-strip-types scripts/examples.ts",
12 | "generate:lib": "node --experimental-strip-types scripts/types.ts",
13 | "fmt": "dprint fmt",
14 | "check": "tsc --noEmit --p tsconfig.app.json",
15 | "dev:bin": "node scripts/cli.js --examples=fakeExamples",
16 | "prepare": "cd kaplay && pnpm i && cd .. && npm run generate:lib && npm run generate:examples",
17 | "sandbox:build": "cd sandbox && vite build",
18 | "sandbox:deploy": "cd sandbox && wrangler pages deploy"
19 | },
20 | "dependencies": {
21 | "@fontsource-variable/outfit": "^5.2.5",
22 | "@fontsource-variable/rubik": "^5.1.0",
23 | "@fontsource/dm-mono": "^5.2.5",
24 | "@formkit/drag-and-drop": "^0.0.38",
25 | "@kaplayjs/crew": "2.0.0-beta.3",
26 | "@monaco-editor/react": "^4.6.0",
27 | "@radix-ui/react-context-menu": "^2.2.2",
28 | "@radix-ui/react-dropdown-menu": "^2.1.14",
29 | "@radix-ui/react-tabs": "^1.1.1",
30 | "@radix-ui/react-toggle-group": "^1.1.3",
31 | "allotment": "^1.20.3",
32 | "canvas-confetti": "^1.9.3",
33 | "clsx": "^2.1.1",
34 | "console-feed": "^3.8.0",
35 | "daisyui": "^4.12.13",
36 | "esbuild-wasm": "^0.25.4",
37 | "magic-string": "^0.30.11",
38 | "monaco-editor": "0.48.0",
39 | "pako": "^2.1.0",
40 | "query-registry": "^3.0.1",
41 | "react": "^18.3.1",
42 | "react-dom": "^18.3.1",
43 | "react-dropzone": "^14.2.9",
44 | "react-responsive": "^10.0.0",
45 | "react-router-dom": "^7.5.2",
46 | "react-sparkle": "^2.0.0",
47 | "react-toastify": "^10.0.5",
48 | "react-tooltip": "^5.28.0",
49 | "tailwind-merge": "^2.5.3",
50 | "tailwindcss": "^3.4.13",
51 | "theme-change": "^2.5.0",
52 | "tweenkie": "^1.0.1",
53 | "typescript": "^5.6.3",
54 | "zustand": "^4.5.5"
55 | },
56 | "devDependencies": {
57 | "@kaplayjs/dprint-config": "^1.1.0",
58 | "@neutralinojs/neu": "^11.3.0",
59 | "@types/canvas-confetti": "^1.9.0",
60 | "@types/node": "^22.7.5",
61 | "@types/pako": "^2.0.3",
62 | "@types/react": "^18.3.11",
63 | "@types/react-dom": "^18.3.0",
64 | "@vitejs/plugin-react": "^4.3.2",
65 | "autoprefixer": "^10.4.20",
66 | "comment-parser": "^1.4.1",
67 | "dprint": "^0.49.1",
68 | "kaplay": "3001.0.0",
69 | "postcss": "^8.4.47",
70 | "vite": "^5.4.8",
71 | "vite-plugin-custom-env": "^1.0.3",
72 | "vite-plugin-static-copy": "^2.3.1"
73 | },
74 | "packageManager": "pnpm@9.6.0+sha512.38dc6fba8dba35b39340b9700112c2fe1e12f10b17134715a4aa98ccf7bb035e76fd981cf0bb384dfa98f8d6af5481c2bef2f4266a24bfa20c34eb7147ce0b5e"
75 | }
76 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/pg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaplayjs/kaplayground/6ebafc1b39d510fc5bd73e516555604c157c18ae/public/pg.png
--------------------------------------------------------------------------------
/sandbox/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Game Preview
8 |
26 |
27 |
28 |
29 |
99 |
100 |
101 |
--------------------------------------------------------------------------------
/sandbox/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react";
2 | import { defineConfig } from "vite";
3 | import { viteStaticCopy } from "vite-plugin-static-copy";
4 |
5 | export default defineConfig({
6 | plugins: [
7 | viteStaticCopy({
8 | targets: [
9 | {
10 | // all except js files
11 | src: "../kaplay/examples/**/!(*.js)",
12 | dest: "",
13 | },
14 | ],
15 | }),
16 | ],
17 | });
18 |
--------------------------------------------------------------------------------
/sandbox/wrangler.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "name": "iframe-kaplay",
3 | "compatibility_date": "2025-04-27",
4 | "pages_build_output_dir": "dist"
5 | }
--------------------------------------------------------------------------------
/scripts/examples.ts:
--------------------------------------------------------------------------------
1 | // A script that gets all the examples on kaplay/examples folder and generates a
2 | // list of examples with code and name.
3 |
4 | import { execSync } from "child_process";
5 | import { parse } from "comment-parser";
6 | import fs from "fs";
7 | import path from "path";
8 | import type { Packument } from "query-registry";
9 | import examplesData from "../kaplay/examples/examples.json" with {
10 | type: "json",
11 | };
12 |
13 | // @ts-ignore
14 | async function getPackageInfo(name: string): Promise {
15 | const endpoint = `https://registry.npmjs.org/${name}`;
16 | const res = await fetch(endpoint);
17 | const data = await res.json();
18 | return data as Packument;
19 | }
20 |
21 | const defaultExamplesPath = path.join(
22 | import.meta.dirname,
23 | "..",
24 | "kaplay",
25 | "examples",
26 | );
27 | const distPath = path.join(import.meta.dirname, "..", "src", "data");
28 |
29 | export const generateExamples = async (examplesPath = defaultExamplesPath) => {
30 | let exampleCount = 0;
31 |
32 | const examples = fs.readdirSync(examplesPath).map((file) => {
33 | if (!file.endsWith(".js")) return null;
34 |
35 | const filePath = path.join(examplesPath, file);
36 | const code = fs.readFileSync(filePath, "utf-8");
37 | const name = file.replace(".js", "");
38 |
39 | const codeJsdoc = parse(code);
40 | const codeWithoutMeta = code.replace(/\/\/ @ts-check\n/g, "").replace(
41 | /\/\*\*[\s\S]*?\*\//gm,
42 | "",
43 | ).trim();
44 |
45 | if (!codeWithoutMeta) return null;
46 |
47 | const tags = codeJsdoc[0]?.tags?.reduce(
48 | (acc, tag) => {
49 | acc[tag.tag] = [tag.name.trim(), tag.description.trim()].filter(
50 | t => t != "",
51 | ).join(" ");
52 | return acc;
53 | },
54 | {} as Record,
55 | );
56 |
57 | const sortName = [
58 | examplesData.categories?.[tags?.category]?.order ?? 9999,
59 | tags?.category,
60 | tags?.group ?? "zzzz",
61 | tags?.groupOrder ?? 9999,
62 | name,
63 | ].filter(t => t != undefined).join("-");
64 |
65 | const example: Record = {
66 | id: exampleCount++,
67 | name,
68 | formattedName: tags?.file?.trim() || name,
69 | sortName,
70 | category: tags?.category || "",
71 | group: tags?.group || "",
72 | description: tags?.description || "",
73 | code: codeWithoutMeta,
74 | difficulty: parseInt(tags?.difficulty) ?? 4,
75 | version: "master",
76 | minVersion: (tags?.minver)?.trim() || "noset",
77 | tags: tags?.tags?.trim().split(", ") || [],
78 | createdAt: getFileTimestamp(filePath),
79 | updatedAt: getFileTimestamp(filePath, "updated"),
80 | };
81 |
82 | if (tags?.locked) example.locked = true;
83 |
84 | return example;
85 | });
86 |
87 | // Write a JSON file with the examples
88 | fs.writeFileSync(
89 | path.join(distPath, "exampleList.json"),
90 | JSON.stringify(examples.filter(Boolean), null, 4),
91 | );
92 |
93 | console.log("Generated exampleList.json");
94 | };
95 |
96 | function getFileTimestamp(
97 | filePath: string,
98 | type: "created" | "updated" = "created",
99 | ) {
100 | const cmd = {
101 | created:
102 | `git log --diff-filter=A --follow --format=%aI -1 -- "${filePath}"`,
103 | updated: `git log --follow --format=%aI -1 -- "${filePath}"`,
104 | };
105 |
106 | try {
107 | const stdout = execSync(cmd[type], {
108 | cwd: path.join(import.meta.dirname, "..", "kaplay"),
109 | encoding: "utf8",
110 | });
111 | return stdout.trim();
112 | } catch (err) {
113 | console.log(err);
114 | return "";
115 | }
116 | }
117 |
118 | generateExamples();
119 |
--------------------------------------------------------------------------------
/scripts/types.ts:
--------------------------------------------------------------------------------
1 | // A script that gets all the examples on kaplay/examples folder and generates a
2 | // list of examples with code and name.
3 |
4 | import { readFile, writeFile } from "fs/promises";
5 | import path from "path";
6 |
7 | const docTsPath = path.join(
8 | import.meta.dirname,
9 | "..",
10 | "kaplay",
11 | "dist",
12 | "doc.d.ts",
13 | );
14 |
15 | const globalTsPath = path.join(
16 | import.meta.dirname,
17 | "..",
18 | "kaplay",
19 | "dist",
20 | "declaration",
21 | "global.d.ts",
22 | );
23 |
24 | const libTsPath = path.join(
25 | import.meta.dirname,
26 | "..",
27 | "lib.d.ts",
28 | );
29 |
30 | export const generateTypeLib = async () => {
31 | const docTs = (await readFile(docTsPath, "utf-8")).replace(
32 | // This replace is a workaround to a bug in monaco where it doesn't parse
33 | // code that is after a @example, but looks like @example works
34 |
35 | /@example/gm,
36 | "@example ",
37 | ).replace(
38 | // We do this all replace because Monaco baby trim spaces in code blocks.
39 | // This replace spaces by a some magic character "⠀" that is also invisible,
40 | // Apparently all this works, while ``` ``` are all in their places
41 |
42 | /@example[^\n]*\n(?:\s*\*.*\n)*?\s*\* ```(?:\w+)?\n([\s\S]*?)\n\s*\* ```/gm,
43 | (match, code) => {
44 | const cleanedCode = code.replaceAll(" ", "⠀").replaceAll(
45 | "⠀*",
46 | " *",
47 | );
48 | return match.replace(code, cleanedCode);
49 | },
50 | // Monaco ts version doesn't support syntax, so we replace it
51 | ).replace("add {
11 | return (
12 |
13 | } />
14 | {/* } /> */}
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/src/components/About/AboutDialog.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { REPO, VERSION } from "../../config/common";
3 |
4 | export const AboutDialog = () => {
5 | return (
6 |
99 | );
100 | };
101 |
--------------------------------------------------------------------------------
/src/components/About/index.ts:
--------------------------------------------------------------------------------
1 | export { AboutDialog } from "./AboutDialog";
2 |
--------------------------------------------------------------------------------
/src/components/AssetBrew/AssetBrew.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { useMemo, useState } from "react";
3 | import { AssetBrewItem } from "./AssetBrewItem";
4 |
5 | export const AssetBrew = () => {
6 | const [search, setSearch] = useState("");
7 | const assetList = useMemo(() => {
8 | return Object.keys(assets).filter((key) => {
9 | const k = key as keyof typeof assets;
10 | const asset = assets[k];
11 |
12 | if (search) {
13 | return key.includes(search.toLowerCase())
14 | && asset.category != "fonts"
15 | && key != "superburp";
16 | }
17 |
18 | return asset.category != "fonts" && key != "superburp";
19 | });
20 | }, [search]);
21 |
22 | return (
23 | <>
24 |
25 |
26 |
27 | {
32 | setSearch((e.target as any).value);
33 | }}
34 | >
35 |
36 |
37 | {assetList.map(key => (
38 |
42 | ))}
43 |
44 |
45 | >
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/src/components/AssetBrew/AssetBrewItem.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import type { FC } from "react";
3 | import { insertAfterCursor } from "../../features/Editor/application/insertAfterCursor";
4 |
5 | interface AssetBrewItemProps {
6 | asset: keyof typeof assets;
7 | }
8 |
9 | export const AssetBrewItem: FC = ({ asset }) => {
10 | const handleClick = () => {
11 | insertAfterCursor(
12 | `\nloadSprite("${asset}", "/crew/${asset}.png");`,
13 | );
14 | };
15 |
16 | const handleResourceDrag = (e: React.DragEvent) => {
17 | e.dataTransfer.setData(
18 | "text",
19 | `loadSprite("${asset}", "/crew/${asset}.png");`,
20 | );
21 | };
22 |
23 | return (
24 |
30 |
![{assets[asset].name} {assets[asset].name}]({assets[asset].sprite})
37 |
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/src/components/Assets/Assets.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import * as Tabs from "@radix-ui/react-tabs";
3 | import AssetsPanel from "./AssetsPanel";
4 | import AssetsTab from "./AssetsTab";
5 |
6 | export const Assets = () => {
7 | return (
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
23 |
29 |
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/src/components/Assets/AssetsAddButton.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { type FC } from "react";
3 | import type { AssetKind } from "../../features/Projects/models/AssetKind";
4 |
5 | interface AddButtonProps {
6 | accept: string;
7 | kind: AssetKind;
8 | inputProps?: React.InputHTMLAttributes;
9 | }
10 |
11 | const AssetsAddButton: FC = ({ accept, inputProps }) => {
12 | return (
13 |
14 |
30 |
31 | );
32 | };
33 |
34 | export default AssetsAddButton;
35 |
--------------------------------------------------------------------------------
/src/components/Assets/AssetsItem.tsx:
--------------------------------------------------------------------------------
1 | import * as ContextMenu from "@radix-ui/react-context-menu";
2 | import React, { type FC } from "react";
3 | import type { Asset } from "../../features/Projects/models/Asset";
4 | import { useProject } from "../../features/Projects/stores/useProject";
5 | import { useAssets } from "../../hooks/useAssets";
6 | import { useEditor } from "../../hooks/useEditor";
7 |
8 | export type ResourceProps = {
9 | asset: Asset;
10 | visibleIcon?: string;
11 | };
12 |
13 | const AssetsItem: FC = ({ asset, visibleIcon }) => {
14 | const { removeAsset } = useAssets({
15 | kind: asset.kind,
16 | });
17 | const updateFile = useProject((s) => s.updateFile);
18 | const getAssetsFile = useProject((s) => s.getAssetsFile);
19 | const update = useEditor((s) => s.update);
20 |
21 | const handleResourceDrag = (e: React.DragEvent) => {
22 | e.dataTransfer.setData("text", asset.importFunction);
23 | };
24 |
25 | const handleResourceDelete = () => {
26 | removeAsset(asset.path);
27 | };
28 |
29 | const handleResourceLoad = () => {
30 | const assetsFile = getAssetsFile();
31 | if (!assetsFile) return;
32 |
33 | const newAssetsFile = assetsFile.value + `\n${asset.importFunction}`;
34 |
35 | updateFile("assets.js", newAssetsFile);
36 | update();
37 | };
38 |
39 | return (
40 |
41 |
48 |
49 |
50 |

56 |
57 | {asset.name}
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
69 | Delete
70 |
71 |
75 | Load in assets.js
76 |
77 |
78 |
79 |
80 | );
81 | };
82 |
83 | export default AssetsItem;
84 |
--------------------------------------------------------------------------------
/src/components/Assets/AssetsList.tsx:
--------------------------------------------------------------------------------
1 | import { useDragAndDrop } from "@formkit/drag-and-drop/react";
2 | import { type FC, useEffect } from "react";
3 | import type { Asset } from "../../features/Projects/models/Asset";
4 | import type { AssetKind } from "../../features/Projects/models/AssetKind";
5 | import { useAssets } from "../../hooks/useAssets";
6 | import type { ResourceProps } from "./AssetsItem";
7 | import AssetsItem from "./AssetsItem";
8 |
9 | type Props = Omit & {
10 | kind: AssetKind;
11 | };
12 |
13 | const AssetsList: FC = ({ kind, visibleIcon }) => {
14 | const { assets } = useAssets({ kind });
15 | const [
16 | parent,
17 | draggableAssets,
18 | setDraggableAssets,
19 | ] = useDragAndDrop(assets);
20 |
21 | useEffect(() => {
22 | setDraggableAssets(assets);
23 | }, [assets]);
24 |
25 | return (
26 |
30 | {draggableAssets.map((resource, i) => (
31 |
37 | ))}
38 |
39 | );
40 | };
41 |
42 | export default AssetsList;
43 |
--------------------------------------------------------------------------------
/src/components/Assets/AssetsPanel.css:
--------------------------------------------------------------------------------
1 | .dragging-border {
2 | display: block;
3 | outline: 2px oklch(var(--bc) / 50%) dashed;
4 | outline-offset: -4px;
5 | color: white;
6 | border-radius: 4px;
7 | font-size: 15px;
8 | user-select: none;
9 | }
--------------------------------------------------------------------------------
/src/components/Assets/AssetsPanel.tsx:
--------------------------------------------------------------------------------
1 | import * as Tabs from "@radix-ui/react-tabs";
2 | import * as React from "react";
3 | import Dropzone from "react-dropzone";
4 | import { useAssets } from "../../hooks/useAssets";
5 | import { fileToBase64 } from "../../util/fileToBase64";
6 | import AssetsAddButton from "./AssetsAddButton";
7 | import AssetsList from "./AssetsList";
8 | import "./AssetsPanel.css";
9 | import type { AssetKind } from "../../features/Projects/models/AssetKind";
10 | import { useEditor } from "../../hooks/useEditor";
11 | import { cn } from "../../util/cn";
12 |
13 | type Props = {
14 | value: string;
15 | kind: AssetKind;
16 | visibleIcon?: string;
17 | accept: string;
18 | };
19 |
20 | const AssetsPanel: React.FC = (props) => {
21 | const { addAsset } = useAssets({ kind: props.kind });
22 | const showNotification = useEditor((s) => s.showNotification);
23 | const [isDragging, setIsDragging] = React.useState(false);
24 |
25 | const handleAssetUpload = async (acceptedFiles: File[]) => {
26 | if (acceptedFiles.length === 0) return;
27 |
28 | acceptedFiles.forEach(async (file) => {
29 | try {
30 | addAsset({
31 | name: file.name,
32 | url: await fileToBase64(file),
33 | kind: props.kind,
34 | path: `${props.kind}s/${file.name}`,
35 | });
36 | } catch (e) {
37 | console.error(e);
38 | }
39 | });
40 | };
41 |
42 | return (
43 |
49 | {
53 | setIsDragging(true);
54 | }}
55 | onDragLeave={() => {
56 | setIsDragging(false);
57 | }}
58 | onDropAccepted={() => {
59 | setIsDragging(false);
60 | }}
61 | onDropRejected={() => {
62 | showNotification("Invalid file type!");
63 | setIsDragging(false);
64 | }}
65 | >
66 | {({ getRootProps, getInputProps }) => (
67 |
83 | )}
84 |
85 |
86 | );
87 | };
88 |
89 | export default AssetsPanel;
90 |
--------------------------------------------------------------------------------
/src/components/Assets/AssetsTab.tsx:
--------------------------------------------------------------------------------
1 | import * as Tabs from "@radix-ui/react-tabs";
2 | import type { FC } from "react";
3 |
4 | type TabProps = {
5 | label: string;
6 | icon: string;
7 | };
8 |
9 | const AssetsTab: FC = ({ label, icon }) => {
10 | return (
11 |
15 |
16 |
{label}
17 |

22 |
23 |
24 | );
25 | };
26 |
27 | export default AssetsTab;
28 |
--------------------------------------------------------------------------------
/src/components/Assets/index.ts:
--------------------------------------------------------------------------------
1 | export { Assets } from "./Assets";
2 |
--------------------------------------------------------------------------------
/src/components/Config/ConfigDialog.tsx:
--------------------------------------------------------------------------------
1 | import { type Config, useConfig } from "../../hooks/useConfig";
2 | import { stringToValue } from "../../util/stringToValue.ts";
3 | import { Dialog } from "../UI/Dialog";
4 | import { ConfigEditor } from "./ConfigEditor.tsx";
5 |
6 | // Handle the change of options in the Configuration dialog
7 | const ConfigDialog = () => {
8 | const { setConfigKey } = useConfig();
9 |
10 | const handleSave = () => {
11 | const configElList = document.querySelectorAll("*[data-config]") ?? [];
12 |
13 | // Reproduce saved options in configuration state
14 | configElList.forEach((el) => {
15 | const configEl = el as HTMLSelectElement | HTMLInputElement;
16 | const configKey = configEl.dataset["config"] as keyof Config;
17 | const configValue = stringToValue(configEl.dataset["value"]!);
18 |
19 | setConfigKey(configKey, configValue);
20 | });
21 | };
22 |
23 | return (
24 |
27 | );
28 | };
29 |
30 | export default ConfigDialog;
31 |
--------------------------------------------------------------------------------
/src/components/Config/ConfigEditor.tsx:
--------------------------------------------------------------------------------
1 | import { ConfigCheckbox } from "./ConfigForm/ConfigCheckbox.tsx";
2 | import { ConfigSelect } from "./ConfigForm/ConfigSelect.tsx";
3 |
4 | export const ConfigEditor = () => {
5 | const handleDeleteAllData = () => {
6 | if (confirm("Are you sure you want to delete all data?")) {
7 | localStorage.clear();
8 | location.reload();
9 | }
10 | };
11 |
12 | return (
13 |
14 |
15 | Editor Configuration
16 |
17 |
18 |
19 |
20 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/src/components/Config/ConfigForm/ConfigCheckbox.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { type Config, useConfig } from "../../../hooks/useConfig.ts";
3 |
4 | type OnlyBooleans = {
5 | [K in keyof T as T[K] extends boolean ? K : never]: T[K];
6 | };
7 |
8 | export interface ConfigCheckboxProps {
9 | label: string;
10 | configKey: keyof OnlyBooleans;
11 | }
12 |
13 | export const ConfigCheckbox = (props: ConfigCheckboxProps) => {
14 | const { config } = useConfig();
15 | const [checked, setChecked] = useState(config[props.configKey]);
16 |
17 | useEffect(() => {
18 | setChecked(config[props.configKey]);
19 | }, [config[props.configKey]]);
20 |
21 | return (
22 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/src/components/Config/ConfigForm/ConfigSelect.tsx:
--------------------------------------------------------------------------------
1 | import { type PropsWithChildren } from "react";
2 | import { type Config, useConfig } from "../../../hooks/useConfig.ts";
3 |
4 | export interface ConfigSelectProps extends PropsWithChildren {
5 | label: string;
6 | configKey: keyof Config;
7 | }
8 |
9 | export const ConfigSelect = (props: ConfigSelectProps) => {
10 | const { config } = useConfig();
11 |
12 | return (
13 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/src/components/ConsoleView/ConsoleView.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { Console, Hook, Unhook } from "console-feed";
3 | import { useEffect, useState } from "react";
4 |
5 | export const ConsoleView = () => {
6 | const [logs, setLogs] = useState([]);
7 |
8 | useEffect(() => {
9 | const hookedConsole = Hook(
10 | window.console,
11 | (log) => {
12 | if (log.data?.[0] !== "[game]") return;
13 |
14 | setLogs((currLogs) => [...currLogs, log]);
15 | },
16 | false,
17 | );
18 |
19 | window.addEventListener("message", (event) => {
20 | if (
21 | event.data?.type?.startsWith("CONSOLE_")
22 | && String(event.data?.data?.[0])?.startsWith("[sandbox]")
23 | ) return;
24 |
25 | if (event.data?.type === "CONSOLE_LOG") {
26 | const log: string[] = event.data?.data;
27 |
28 | console.log(
29 | "[game]",
30 | ...log,
31 | );
32 | } else if (event.data?.type === "CONSOLE_ERROR") {
33 | const log: string[] = event.data?.data;
34 |
35 | console.error(
36 | "[game]",
37 | ...log,
38 | );
39 | } else if (event.data?.type === "CONSOLE_WARN") {
40 | const log: string[] = event.data?.data;
41 |
42 | console.warn(
43 | "[game]",
44 | ...log,
45 | );
46 | } else if (event.data?.type === "CONSOLE_DEBUG") {
47 | const log: string[] = event.data?.data;
48 |
49 | console.debug(
50 | "[game]",
51 | ...log,
52 | );
53 | } else if (event.data?.type === "CONSOLE_INFO") {
54 | const log: string[] = event.data?.data;
55 |
56 | console.info(
57 | "[game]",
58 | ...log,
59 | );
60 | }
61 | });
62 |
63 | return () => {
64 | Unhook(hookedConsole);
65 | };
66 | }, []);
67 |
68 | useEffect(() => {
69 | const consoleWrapper = document.getElementById("console-wrapper");
70 | consoleWrapper?.scroll({
71 | top: consoleWrapper.scrollHeight,
72 | });
73 | }, [logs]);
74 |
75 | return (
76 |
80 |
81 | {logs.length > 0 && (
82 |
83 |
96 |
97 | )}
98 |
99 |
124 |
125 | {logs.length == 0 && (
126 |
127 | >
128 | Console is empty
129 |
130 | )}
131 |
132 |
133 | );
134 | };
135 |
--------------------------------------------------------------------------------
/src/components/Editor/MonacoEditor.tsx:
--------------------------------------------------------------------------------
1 | import { Editor, type Monaco } from "@monaco-editor/react";
2 | import confetti from "canvas-confetti";
3 | import type { editor } from "monaco-editor";
4 | import { type FC, useEffect } from "react";
5 | import { useProject } from "../../features/Projects/stores/useProject";
6 | import { useConfig } from "../../hooks/useConfig.ts";
7 | import { useEditor } from "../../hooks/useEditor";
8 | import { debug } from "../../util/logs";
9 | import { formatAction } from "./actions/format";
10 | import { configMonaco } from "./monacoConfig";
11 |
12 | type Props = {
13 | onMount?: () => void;
14 | defaultTheme?: string;
15 | };
16 |
17 | export const MonacoEditor: FC = (props) => {
18 | const updateFile = useProject((s) => s.updateFile);
19 | const getFile = useProject((s) => s.getFile);
20 | const run = useEditor((s) => s.run);
21 | const update = useEditor((s) => s.update);
22 | const updateImageDecorations = useEditor((s) => s.updateImageDecorations);
23 | const setRuntime = useEditor((s) => s.setRuntime);
24 | const getRuntime = useEditor((s) => s.getRuntime);
25 | const getConfig = useConfig((s) => s.getConfig);
26 | const setConfigKey = useConfig((s) => s.setConfigKey);
27 |
28 | const handleEditorBeforeMount = (monaco: Monaco) => {
29 | configMonaco(monaco);
30 | };
31 |
32 | const handleEditorMount = (
33 | editor: editor.IStandaloneCodeEditor,
34 | monaco: Monaco,
35 | ) => {
36 | setRuntime({ editor, monaco });
37 | const currentFile = getRuntime().currentFile;
38 |
39 | // Create canvas
40 | const canvas = document.createElement("canvas") as HTMLCanvasElement & {
41 | confetti: confetti.CreateTypes;
42 | };
43 | canvas.style.position = "absolute";
44 | canvas.style.pointerEvents = "none"; // Prevent interactions
45 | canvas.style.top = "0";
46 | canvas.style.left = "0";
47 | canvas.style.width = "100%";
48 | canvas.style.height = "100%";
49 |
50 | document.getElementById("monaco-editor-wrapper")!.appendChild(canvas);
51 |
52 | // Confetti thing setup
53 | canvas.confetti = confetti.create(canvas, { resize: true });
54 |
55 | props.onMount?.();
56 | editor.setValue(getFile(currentFile)?.value ?? "");
57 |
58 | editor.onDidChangeModelContent((ev) => {
59 | if (ev.isFlush) {
60 | } else {
61 | const currentProjectFile = getFile(getRuntime().currentFile);
62 | if (!currentProjectFile) {
63 | return debug(0, "Current file not found");
64 | }
65 |
66 | debug(
67 | 0,
68 | "Due to text editor change, updating file",
69 | currentProjectFile.path,
70 | );
71 |
72 | updateFile(currentProjectFile.path, editor.getValue());
73 | }
74 |
75 | updateImageDecorations();
76 | });
77 |
78 | editor.onDidChangeModel((e) => {
79 | console.log(
80 | "tried to change model to",
81 | e.oldModelUrl,
82 | e.newModelUrl,
83 | );
84 | updateImageDecorations();
85 | });
86 |
87 | editor.onDidScrollChange(() => {
88 | updateImageDecorations();
89 | });
90 |
91 | editor.onDidChangeModelDecorations(() => {
92 | const decorations = document.querySelectorAll(
93 | ".monaco-glyph-margin-preview-image",
94 | );
95 |
96 | decorations.forEach((e, i) => {
97 | const decRange = getRuntime().gylphDecorations?.getRange(i);
98 | if (!decRange) return;
99 |
100 | const dec = editor.getDecorationsInRange(decRange)?.[0];
101 | const realImage = dec?.options.hoverMessage!;
102 |
103 | if (!Array.isArray(realImage) && realImage?.value) {
104 | e.style.setProperty("--image", `url("${realImage.value}")`);
105 | }
106 | });
107 | });
108 |
109 | // Editor Shortcuts
110 | editor.addAction({
111 | id: "run-game",
112 | label: "Run Game",
113 | keybindings: [
114 | monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
115 | ],
116 | contextMenuGroupId: "navigation",
117 | contextMenuOrder: 1.5,
118 | run: () => {
119 | run();
120 |
121 | if (getConfig().autoFormat) {
122 | editor.getAction("format-kaplay")?.run();
123 | }
124 | },
125 | });
126 |
127 | editor.addAction({
128 | id: "sync-file",
129 | label: "Sync File with Project",
130 | contextMenuGroupId: "navigation",
131 | contextMenuOrder: 1.5,
132 | run: () => {
133 | update();
134 | },
135 | });
136 |
137 | editor.addAction(formatAction(editor, canvas));
138 |
139 | editor.addAction({
140 | id: "toggle-word-wrap",
141 | label: "Toggle Word Wrap",
142 | keybindings: [
143 | monaco.KeyMod.Alt | monaco.KeyCode.KeyZ,
144 | ],
145 | run: () => {
146 | const isOn = editor.getRawOptions().wordWrap === "on";
147 | editor.updateOptions({ wordWrap: isOn ? "off" : "on" });
148 | setConfigKey("wordWrap", !isOn);
149 | },
150 | });
151 |
152 | let decorations = editor.createDecorationsCollection([]);
153 |
154 | setRuntime({
155 | gylphDecorations: decorations,
156 | });
157 |
158 | updateImageDecorations();
159 | run();
160 | };
161 |
162 | useEffect(() => {
163 | useConfig.subscribe((state) => {
164 | useEditor.getState().runtime.editor?.updateOptions({
165 | wordWrap: state.config.wordWrap ? "on" : "off",
166 | });
167 | });
168 | }, []);
169 |
170 | return (
171 |
172 |
206 |
207 | );
208 | };
209 |
--------------------------------------------------------------------------------
/src/components/Editor/actions/format.ts:
--------------------------------------------------------------------------------
1 | import { CreateTypes } from "canvas-confetti";
2 | import * as monaco from "monaco-editor";
3 | import { useConfig } from "../../../hooks/useConfig";
4 |
5 | export const formatAction = (
6 | editor: monaco.editor.IStandaloneCodeEditor,
7 | canvas: HTMLCanvasElement & {
8 | confetti: CreateTypes;
9 | },
10 | ): monaco.editor.IActionDescriptor => ({
11 | id: "format-kaplay",
12 | label: "Format file using KAPLAYGROUND",
13 | contextMenuGroupId: "navigation",
14 | contextMenuOrder: 1.5,
15 | run: async () => {
16 | const oldContent = editor.getValue();
17 |
18 | await editor.getAction("editor.action.formatDocument")?.run();
19 |
20 | const newContent = editor.getValue();
21 |
22 | if (oldContent === newContent) {
23 | return;
24 | }
25 |
26 | if (!useConfig.getState().config.funFormat) return;
27 |
28 | var duration = 0.5 * 1000;
29 | var animationEnd = Date.now() + duration;
30 |
31 | (function frame() {
32 | var timeLeft = animationEnd - Date.now();
33 |
34 | canvas.confetti({
35 | particleCount: 3,
36 | spread: 1,
37 | origin: {
38 | x: Math.random(),
39 | y: -0.05,
40 | },
41 | angle: 270,
42 | startVelocity: 10,
43 | gravity: 0.5,
44 | ticks: 50,
45 | colors: ["#fcef8d", "#abdd64", "#d46eb3"],
46 | });
47 |
48 | if (timeLeft > 0) {
49 | requestAnimationFrame(frame);
50 | }
51 | })();
52 | },
53 | });
54 |
--------------------------------------------------------------------------------
/src/components/Editor/completionProviders.ts:
--------------------------------------------------------------------------------
1 | import { languages } from "monaco-editor";
2 | import { useEditor } from "../../hooks/useEditor.ts";
3 | import { compMap } from "./snippets/compSnippets.ts";
4 |
5 | type CompletionProviderFunc =
6 | languages.CompletionItemProvider["provideCompletionItems"];
7 |
8 | export const addCompletion: CompletionProviderFunc = (model, pos) => {
9 | const monaco = useEditor.getState().runtime.monaco!;
10 | const suggestions: languages.CompletionItem[] = [];
11 |
12 | const textBeforeCursor = model.getValueInRange({
13 | startLineNumber: 1,
14 | startColumn: 1,
15 | endLineNumber: pos.lineNumber,
16 | endColumn: pos.column,
17 | });
18 |
19 | const textAfterCursor = model.getValueInRange({
20 | startLineNumber: pos.lineNumber,
21 | startColumn: pos.column,
22 | endLineNumber: model.getLineCount(),
23 | endColumn: model.getLineMaxColumn(model.getLineCount()),
24 | });
25 |
26 | // Check that we're inside something like "add(["
27 | const isInsideAdd = /(?:\b(?:k\.)?add\s*\(\s*\[)[^\]]*$/.test(
28 | textBeforeCursor,
29 | );
30 |
31 | // Check that the array is not closed yet after the cursor
32 | const isInsideArray = /^[^\]]*\]/.test(textAfterCursor);
33 |
34 | // Count brackets only inside the array (after the `add([`)
35 | const textInsideArray =
36 | textBeforeCursor.split(/(?:\b(?:k\.)?add\s*\(\s*\[)/).pop() || "";
37 |
38 | let openParens = 0;
39 | let openBrackets = 0;
40 | let openBraces = 0;
41 |
42 | for (const char of textInsideArray) {
43 | if (char === "(") openParens++;
44 | if (char === ")") openParens--;
45 | if (char === "[") openBrackets++;
46 | if (char === "]") openBrackets--;
47 | if (char === "{") openBraces++;
48 | if (char === "}") openBraces--;
49 | }
50 |
51 | const isInsideNested = openParens > 0 || openBrackets > 0
52 | || openBraces > 0;
53 |
54 | if (isInsideAdd && isInsideArray && !isInsideNested) {
55 | const word = model.getWordUntilPosition(pos);
56 | const range = new monaco.Range(
57 | pos.lineNumber,
58 | word.startColumn,
59 | pos.lineNumber,
60 | word.endColumn,
61 | );
62 |
63 | Object.keys(compMap).forEach((compId, i) => {
64 | const comp = compMap[compId];
65 |
66 | suggestions.push({
67 | label: `${comp.prettyName} - ${comp.description}`,
68 | documentation: {
69 | value:
70 | `${comp.description}\n\n[Check in KAPLAY Docs](https://kaplayjs.com/doc/ctx/${compId})`,
71 | isTrusted: true,
72 | },
73 | kind: monaco.languages.CompletionItemKind.Function,
74 | insertText: comp.template,
75 | insertTextRules: monaco.languages.CompletionItemInsertTextRule
76 | .InsertAsSnippet,
77 | range,
78 | sortText: "000" + i,
79 | command: {
80 | id: "editor.action.triggerSuggest",
81 | title: "Re-trigger completions",
82 | tooltip: "Re-trigger completions",
83 | },
84 | });
85 | });
86 | }
87 |
88 | return { suggestions };
89 | };
90 |
--------------------------------------------------------------------------------
/src/components/Editor/monacoConfig.ts:
--------------------------------------------------------------------------------
1 | import type { Monaco } from "@monaco-editor/react";
2 | import docTs from "../../../lib.d.ts?raw";
3 | import { useProject } from "../../features/Projects/stores/useProject";
4 | import { useEditor } from "../../hooks/useEditor";
5 | import { DATA_URL_REGEX } from "../../util/regex";
6 | import { KAPLAYSnippets } from "./completion/KAPLAYSnippets";
7 | import { addCompletion } from "./completionProviders.ts";
8 | import { themes } from "./themes/themes.ts";
9 |
10 | let providersRegistered = false;
11 |
12 | // create monaco instance
13 |
14 | export const configMonaco = (monaco: Monaco) => {
15 | if (providersRegistered) return;
16 | providersRegistered = true;
17 |
18 | // Add global KAPLAY types
19 | monaco.languages.typescript.javascriptDefaults.addExtraLib(
20 | docTs,
21 | "kaplay.d.ts",
22 | );
23 |
24 | monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
25 | noSemanticValidation: false,
26 | noSyntaxValidation: false,
27 | });
28 |
29 | monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true);
30 | monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true);
31 |
32 | monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
33 | allowNonTsExtensions: true,
34 | allowJs: true,
35 | moduleResolution: 2,
36 | baseUrl: ".",
37 | paths: {
38 | "*": ["*"],
39 | },
40 | });
41 |
42 | monaco.editor.registerEditorOpener({
43 | openCodeEditor(_source, resource, selectionOrPosition) {
44 | if (useProject.getState().hasFile(resource.path)) {
45 | useEditor.getState().setCurrentFile(resource.path);
46 | // use selectionOrPosition to set the cursor position
47 |
48 | if (
49 | selectionOrPosition
50 | && monaco.Range.isIRange(selectionOrPosition)
51 | ) {
52 | const range = new monaco.Range(
53 | selectionOrPosition.startLineNumber,
54 | selectionOrPosition.startColumn,
55 | selectionOrPosition.endLineNumber,
56 | selectionOrPosition.endColumn,
57 | );
58 |
59 | useEditor.getState().getRuntime().editor?.setSelection(
60 | range,
61 | );
62 | } else if (selectionOrPosition) {
63 | const position = new monaco.Position(
64 | selectionOrPosition.lineNumber,
65 | selectionOrPosition.column,
66 | );
67 |
68 | useEditor.getState().getRuntime().editor?.setPosition(
69 | position,
70 | );
71 | }
72 |
73 | return true;
74 | }
75 |
76 | return false;
77 | },
78 | });
79 |
80 | // Hover dataUrl images
81 | monaco.languages.registerHoverProvider("javascript", {
82 | provideHover(model, position) {
83 | const line = model.getLineContent(position.lineNumber);
84 | const dataUrisInLine = line.match(DATA_URL_REGEX);
85 | if (!dataUrisInLine) return null;
86 |
87 | const lineIndex = position.lineNumber - 1;
88 | const charIndex = line.indexOf(dataUrisInLine[0]);
89 | const length = dataUrisInLine[0].length;
90 |
91 | return {
92 | range: new monaco.Range(
93 | lineIndex,
94 | charIndex,
95 | lineIndex,
96 | length,
97 | ),
98 | contents: [
99 | {
100 | supportHtml: true,
101 | value: `
`,
102 | },
103 | ],
104 | };
105 | },
106 | });
107 |
108 | monaco.languages.registerCompletionItemProvider(
109 | "javascript",
110 | new KAPLAYSnippets(),
111 | );
112 |
113 | monaco.languages.registerCompletionItemProvider("javascript", {
114 | provideCompletionItems(...args) {
115 | return addCompletion(...args);
116 | },
117 | });
118 |
119 | // Themes
120 | monaco.editor.defineTheme("Spiker", themes.Spiker);
121 | };
122 |
--------------------------------------------------------------------------------
/src/components/Editor/snippets/compSnippets.ts:
--------------------------------------------------------------------------------
1 | type CompCompletionData = {
2 | prettyName: string;
3 | description: string;
4 | template: string;
5 | };
6 |
7 | export const compMap: Record = {
8 | "pos": {
9 | prettyName: "pos(x, y)",
10 | description: "Set a coords on screen",
11 | template: "pos(${1:x}, ${2:y}),",
12 | },
13 | "scale": {
14 | prettyName: "scale(s)",
15 | description: "Set how big it is",
16 | template: "scale(${1:s}),",
17 | },
18 | "rotate": {
19 | prettyName: "rotate(r)",
20 | description: "Set the angle",
21 | template: "rotate(${1:r}),",
22 | },
23 | "color": {
24 | prettyName: "color(\"#00ff00\")",
25 | description: "Set a color",
26 | template: "color(\"#${1:}\"),",
27 | },
28 | "sprite": {
29 | prettyName: "sprite(\"mark\")",
30 | description: "Render an image",
31 | template: "sprite(\"${1:sprite}\"),",
32 | },
33 | "text": {
34 | prettyName: "text(\"ohhi\")",
35 | description: "Render a text",
36 | template: "sprite(\"${1:sprite}\"),",
37 | },
38 | "polygon": {
39 | prettyName: "polygon([x, y])",
40 | description: "Render a polygon",
41 | template: "polygon([vec2(0,0), vec2(50,0), vec2(50,50), vec2(0,50)]),",
42 | },
43 | "rect": {
44 | prettyName: "rect(w, h)",
45 | description: "Render a rectangle",
46 | template: "rect(${1:w}, ${2:h}),",
47 | },
48 | "circle": {
49 | prettyName: "circle(r)",
50 | description: "Render a circle",
51 | template: "circle(${1:r}),",
52 | },
53 | "uvquad": {
54 | prettyName: "uvquad(w, h)",
55 | description: "Render a uvquad",
56 | template: "uvquad(${1:w}, ${2:h}),",
57 | },
58 | "area": {
59 | prettyName: "area()",
60 | description: "Set an area",
61 | template: "area(),",
62 | },
63 | "anchor": {
64 | prettyName: "anchor(\"center\")",
65 | description: "Set an anchor",
66 | template: "anchor(\"${1:center}\"),",
67 | },
68 | "z": {
69 | prettyName: "z(z)",
70 | description: "Set a z index",
71 | template: "z(${1:z}),",
72 | },
73 | "outline": {
74 | prettyName: "outline(w)",
75 | description: "Set an outline",
76 | template: "outline(${1:w}),",
77 | },
78 | "particles": {
79 | prettyName: "particles()",
80 | description: "Set particles",
81 | template: "particles(todo),",
82 | },
83 | "body": {
84 | prettyName: "body()",
85 | description: "Set a body for physics (req: area())",
86 | template: "body(),",
87 | },
88 | "doubleJump": {
89 | prettyName: "doubleJump(numJumps?)",
90 | description: "Set a double jump (req: body())",
91 | template: "doubleJump(${1}),",
92 | },
93 | "move": {
94 | prettyName: "move(dir, speed)",
95 | description: "Set a move (req: body())",
96 | template: "move(${1:vec2(10, 10)}, ${speed:100}),",
97 | },
98 | "offscreen": {
99 | prettyName: "offscreen()",
100 | description: "Make it do stuff when it's offscreen",
101 | template: "offscreen(),",
102 | },
103 | "follow": {
104 | prettyName: "follow(obj, offset)",
105 | description: "Make it follow another obj",
106 | template: "follow(${1:obj}, ${2:vec2(0, 0)}),",
107 | },
108 | "shader": {
109 | prettyName: "shader(\"name\")",
110 | description: "Set a shader",
111 | template: "shader(\"${1:shader}\"),",
112 | },
113 | "textInput": {
114 | prettyName: "textInput(hasFocus, maxLength)",
115 | description: "Make it a text input",
116 | template: "textInput(),",
117 | },
118 | "timer": {
119 | prettyName: "timer()",
120 | description: "Give it wait, loop and tween in obj",
121 | template: "timer(),",
122 | },
123 | "stay": {
124 | prettyName: "stay()",
125 | description: "Make it stay in scene change",
126 | template: "stay(),",
127 | },
128 | "health": {
129 | prettyName: "health(hp)",
130 | description: "Give it health",
131 | template: "health(${1:hp}),",
132 | },
133 | "lifespan": {
134 | prettyName: "lifespan(time)",
135 | description: "Make it die in time",
136 | template: "lifespan(${1:time}),",
137 | },
138 | "named": {
139 | prettyName: "named(name)",
140 | description: "Give it a name",
141 | template: "named(${1:name}),",
142 | },
143 | "state": {
144 | prettyName: "state()",
145 | description: "Give it a state machine",
146 | template: "state(${1:name}),",
147 | },
148 | "mask": {
149 | prettyName: "mask(maskType?)",
150 | description: "Make it a mask",
151 | template: "mask(${1}),",
152 | },
153 | "drawon": {
154 | prettyName: "drawon(canvas)",
155 | description: "Make it draw on another framebuffer",
156 | template: "drawon(${1:canvas}),",
157 | },
158 | "tile": {
159 | prettyName: "tile()",
160 | description: "Make it a tile in a tilemap",
161 | template: "tile(),",
162 | },
163 | "agent": {
164 | prettyName: "agent()",
165 | description: "Make it an agent",
166 | template: "agent(),",
167 | },
168 | "animate": {
169 | prettyName: "animate()",
170 | description: "Make it animate. My fav comp 😇",
171 | template: "animate(${1:anim}),",
172 | },
173 | "sentry": {
174 | prettyName: "sentry()",
175 | description: "Make it a sentry in pathfinding",
176 | template: "sentry(),",
177 | },
178 | "path": {
179 | prettyName: "path()",
180 | description: "Make it a path in pathfinding",
181 | template: "path(),",
182 | },
183 | "pathfinder": {
184 | prettyName: "pathfinder()",
185 | description: "Make it a pathfinder in pathfinding",
186 | template: "pathfinder(),",
187 | },
188 | };
189 |
--------------------------------------------------------------------------------
/src/components/Editor/themes/themes.ts:
--------------------------------------------------------------------------------
1 | import type { editor } from "monaco-editor";
2 |
3 | export const themes = {
4 | "Spiker": {
5 | base: "vs-dark",
6 | inherit: true,
7 | rules: [
8 | {
9 | "foreground": "c4d6dd",
10 | "token": "identifier",
11 | },
12 | {
13 | "foreground": "a78bfa",
14 | "token": "keyword",
15 | },
16 | {
17 | "foreground": "e9967a",
18 | "token": "string",
19 | },
20 | {
21 | "foreground": "7f848e",
22 | "token": "comment",
23 | },
24 | {
25 | "foreground": "b5d982",
26 | "token": "number",
27 | },
28 | {
29 | "foreground": "b2ccd6",
30 | "token": "delimiter",
31 | },
32 | {
33 | "foreground": "e5c07b",
34 | "token": "variable",
35 | },
36 | {
37 | "foreground": "b2ccd6",
38 | "token": "operator",
39 | },
40 | {
41 | "foreground": "d56c62",
42 | "token": "tag",
43 | },
44 | {
45 | "foreground": "e5c07b",
46 | "token": "attribute",
47 | },
48 | ],
49 | colors: {
50 | "focusBorder": "#abdd64",
51 | "background": "#242933",
52 | "foreground": "#b2ccd6",
53 | "disabledForeground": "#b2ccd640",
54 | "selection.background": "#b2ccd61a",
55 | "icon.foreground": "#b2ccd6",
56 | "sash.hoverBorder": "#abdd64",
57 |
58 | "textLink.foreground": "#abdd64",
59 | "textLink.activeForeground": "#abdd64",
60 |
61 | "input.background": "#2a303c",
62 | "input.foreground": "#b2ccd6",
63 | "inputOption.activeBorder": "#abdd64cc",
64 | "inputOption.activeBackground": "#abdd6433",
65 |
66 | "list.hoverBackground": "#b2ccd61a",
67 | "list.highlightForeground": "#abdd64",
68 | "list.focusHighlightForeground": "#abdd64",
69 |
70 | "widget.shadow": "#0000001A",
71 | "widget.border": "#b2ccd615",
72 |
73 | "editor.background": "#242933",
74 | "editor.foreground": "#b2ccd6",
75 | "editor.selectionBackground": "#465061",
76 | "editor.lineHighlightBackground": "#2b313b",
77 | "editor.lineHighlightBorder": "#00000000",
78 | "editor.findMatchBackground": "#b2ccd620",
79 | "editor.findMatchHighlightBackground": "#b2ccd620",
80 | "editor.findMatchBorder": "#b2ccd615",
81 |
82 | "editorCursor.foreground": "#b5d982",
83 | "editorLineNumber.foreground": "#55626c",
84 | "editorActiveLineNumber.foreground": "#a6adbb",
85 |
86 | "editorIndentGuide.background1": "#b2ccd61a",
87 | "editorIndentGuide.activeBackground1": "#b2ccd640",
88 |
89 | "editorBracketMatch.border": "#b2ccd640",
90 | "editorBracketHighlight.foreground1": "#abdd64",
91 | "editorBracketHighlight.foreground2": "#d46eb3",
92 | "editorBracketHighlight.foreground3": "#6d80fa",
93 |
94 | "editorOverviewRuler.findMatchForeground": "#b2ccd633",
95 |
96 | "scrollbarSlider.background": "#b2ccd633",
97 | "scrollbarSlider.hoverBackground": "#b2ccd64d",
98 | "scrollbarSlider.activeBackground": "#b2ccd680",
99 |
100 | "editorGutter.foldingControlForeground": "#b2ccd6cc",
101 |
102 | "editorWidget.background": "#20252e",
103 | "editorWidget.resizeBorder": "#b2ccd633",
104 |
105 | "editorSuggestWidget.background": "#20252e",
106 | "editorSuggestWidget.border": "#b2ccd61a",
107 | "editorSuggestWidget.foreground": "#b2ccd6",
108 | "editorSuggestWidget.selectedBackground": "#b2ccd61a",
109 | "editorSuggestWidget.highlightForeground": "#abdd64",
110 | "editorSuggestWidget.focusHighlightForeground": "#abdd64",
111 | "editorSuggestWidgetStatus.foreground": "#b2ccd680",
112 |
113 | "editorHoverWidget.background": "#20252e",
114 | "editorHoverWidget.foreground": "#b2ccd6",
115 | "editorHoverWidget.border": "#b2ccd61a",
116 | "editorHoverWidget.statusBarBackground": "#191e24",
117 |
118 | "menu.background": "#2a303c",
119 | "menu.foreground": "#b2ccd6",
120 | "menu.selectionBackground": "#b2ccd61a",
121 | "menu.separatorBackground": "#b2ccd61a",
122 |
123 | "quickInput.background": "#2a303c",
124 | "quickInput.foreground": "#b2ccd6",
125 | "quickInputTitle.background": "#b2ccd61a",
126 | "quickInputList.focusBackground": "#b2ccd64d",
127 | "pickerGroup.foreground": "#abdd64",
128 | },
129 | },
130 | } satisfies {
131 | [key: string]: editor.IStandaloneThemeData;
132 | };
133 |
--------------------------------------------------------------------------------
/src/components/FileTree/FileEntry.css:
--------------------------------------------------------------------------------
1 |
2 | /* Show file actions on hoveronly if kind is not scene nor assets nor kaplay */
3 | .file[data-file-kind="scene"]:hover .file-actions {
4 | display: block;
5 | }
6 |
7 | .file[data-file-kind="util"]:hover .file-actions {
8 | display: block;
9 | }
10 |
11 | .file[data-file-kind="obj"]:hover .file-actions {
12 | display: block;
13 | }
--------------------------------------------------------------------------------
/src/components/FileTree/FileEntry.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import type { FC, MouseEventHandler } from "react";
3 | import { cn } from "../../util/cn";
4 | import { removeExtension } from "../../util/removeExtensions";
5 | import "./FileEntry.css";
6 | import type { File } from "../../features/Projects/models/File";
7 | import type { FileKind } from "../../features/Projects/models/FileKind";
8 | import { useProject } from "../../features/Projects/stores/useProject";
9 | import { useEditor } from "../../hooks/useEditor";
10 |
11 | type Props = {
12 | file: File;
13 | };
14 |
15 | export const logoByKind: Record = {
16 | kaplay: assets.dino.outlined,
17 | scene: assets.art.outlined,
18 | main: assets.play.outlined,
19 | assets: assets.assetbrew.outlined,
20 | obj: assets.grass.outlined,
21 | util: assets.toolbox.outlined,
22 | };
23 |
24 | const FileButton: FC<{
25 | onClick: MouseEventHandler;
26 | icon: keyof typeof assets;
27 | rotate?: 0 | 90 | 180 | 270;
28 | hidden?: boolean;
29 | }> = (props) => {
30 | return (
31 |
43 | );
44 | };
45 |
46 | export const FileEntry: FC = ({ file }) => {
47 | const removeFile = useProject((s) => s.removeFile);
48 | const projectFiles = useProject((s) => s.project.files);
49 | const setProject = useProject((s) => s.setProject);
50 | const setCurrentFile = useEditor((s) => s.setCurrentFile);
51 | const currentFile = useEditor((s) => s.runtime.currentFile);
52 |
53 | const isRoot = () => !file.path.includes("/");
54 |
55 | const handleClick: MouseEventHandler = () => {
56 | setCurrentFile(file.path);
57 | };
58 |
59 | const handleDelete: MouseEventHandler = (e) => {
60 | e.stopPropagation();
61 |
62 | if (file.kind === "kaplay" || file.kind === "main") {
63 | return alert("You cannot remove this file");
64 | }
65 |
66 | if (confirm("Are you sure you want to remove this scene?")) {
67 | removeFile(file.path);
68 | setCurrentFile("main.js");
69 | }
70 | };
71 |
72 | const handleMoveUp: MouseEventHandler = (e) => {
73 | e.stopPropagation();
74 |
75 | // order the map with the file one step up
76 | const files = projectFiles;
77 | const order = Array.from(files.keys());
78 | const index = order.indexOf(file.path);
79 |
80 | if (index === 0) return;
81 |
82 | const newOrder = [...order];
83 | newOrder.splice(index, 1);
84 |
85 | newOrder.splice(index - 1, 0, file.path);
86 |
87 | const newFiles = new Map(
88 | newOrder.map((path) => [path, files.get(path)!]),
89 | );
90 |
91 | setProject({
92 | ...projectFiles,
93 | files: newFiles,
94 | });
95 | };
96 |
97 | const handleMoveDown: MouseEventHandler = (e) => {
98 | e.stopPropagation();
99 |
100 | // order the map with the file one step down
101 | const files = projectFiles;
102 | const order = Array.from(files.keys());
103 | const index = order.indexOf(file.path);
104 |
105 | if (index === order.length - 1) return;
106 |
107 | const newOrder = [...order];
108 | newOrder.splice(index, 1);
109 |
110 | newOrder.splice(index + 1, 0, file.path);
111 |
112 | const newFiles = new Map(
113 | newOrder.map((path) => [path, files.get(path)!]),
114 | );
115 |
116 | setProject({
117 | ...projectFiles,
118 | files: newFiles,
119 | });
120 | };
121 |
122 | return (
123 |
135 | {isRoot() && (
136 |

141 | )}
142 |
143 | {removeExtension(file.name)}
144 |
145 |
146 |
150 |
155 |
160 |
161 |
162 | );
163 | };
164 |
--------------------------------------------------------------------------------
/src/components/FileTree/FileFold.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { type FC, type PropsWithChildren, useState } from "react";
3 | import { cn } from "../../util/cn";
4 | import { FileToolbar } from "./FileToolbar";
5 | import "./FileFolder.css";
6 | import { useShallow } from "zustand/react/shallow";
7 | import type { FileFolder } from "../../features/Projects/models/FileFolder";
8 | import type { FileKind } from "../../features/Projects/models/FileKind";
9 | import { useProject } from "../../features/Projects/stores/useProject";
10 | import { FileEntry, logoByKind } from "./FileEntry";
11 |
12 | type Props = PropsWithChildren<{
13 | level: 0 | 1 | 2;
14 | title?: string;
15 | toolbar?: boolean;
16 | /** Kind of files on Folder */
17 | kind?: FileKind;
18 | /** Folder */
19 | folder: FileFolder;
20 | folded?: boolean;
21 | }>;
22 |
23 | const paddingLevels = {
24 | 0: "pl-0",
25 | 1: "pl-2.5",
26 | 2: "pl-5",
27 | };
28 |
29 | export const FileFold: FC = (props) => {
30 | const getFilesByFolder = useProject((s) => s.getFilesByFolder);
31 | const [folded, setFolded] = useState(props.folded ?? false);
32 |
33 | useProject(useShallow(s => {
34 | return s.getTree(props.folder);
35 | }));
36 |
37 | const files = getFilesByFolder(props.folder);
38 |
39 | return (
40 |
41 |
42 | {props.title && props.kind && (
43 |
44 |

48 |
49 | {props.title}
50 |
51 |
52 | )}
53 |
54 | {(props.toolbar && props.kind) && (
55 |
58 |
69 |
70 | )}
71 |
72 |
73 |
83 | {files.length === 0
84 | ? (
85 | -
86 | Create {props.kind === "obj" ? "an" : "a"}{" "}
87 | {props.kind} to start
88 |
89 | )
90 | : (
91 | files.map((file) => {
92 | return (
93 | -
94 |
97 |
98 | );
99 | })
100 | )}
101 |
102 |
103 | );
104 | };
105 |
--------------------------------------------------------------------------------
/src/components/FileTree/FileFolder.css:
--------------------------------------------------------------------------------
1 | .folded-icon[data-folded="true"] {
2 | transform: rotate(0deg);
3 | }
4 |
5 | .folded-icon[data-folded="false"] {
6 | transform: rotate(90deg);
7 | }
--------------------------------------------------------------------------------
/src/components/FileTree/FileToolbar.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import type { FC, PropsWithChildren } from "react";
3 | import type { FileKind } from "../../features/Projects/models/FileKind";
4 | import { folderByKind } from "../../features/Projects/stores/slices/files";
5 | import { useProject } from "../../features/Projects/stores/useProject";
6 |
7 | type Props = PropsWithChildren<{
8 | kind: FileKind;
9 | }>;
10 |
11 | const templateByKind = (fileName: string): Record => ({
12 | assets: `// User can't create this`,
13 | kaplay: `// User can't create this`,
14 | main: `// User can't create this`,
15 | scene: `scene("${fileName}", () => {\n\n});\n`,
16 | obj: `function add${fileName}() {\n\n}\n`,
17 | util: `function ${fileName}() {\n\n}\n`,
18 | });
19 |
20 | export const FileToolbar: FC = (props) => {
21 | const addFile = useProject((s) => s.addFile);
22 | const getFile = useProject((s) => s.getFile);
23 |
24 | const handleAddFile = () => {
25 | const fileName = prompt("File name");
26 | if (!fileName) return;
27 | if (getFile(`${folderByKind[props.kind]}/${fileName}.js`)) return;
28 |
29 | addFile({
30 | name: fileName + ".js",
31 | kind: props.kind,
32 | value: templateByKind(fileName)[props.kind],
33 | language: "javascript",
34 | path: `${folderByKind[props.kind]}/${fileName}.js`,
35 | });
36 | };
37 |
38 | return (
39 |
40 |
50 |
51 | {props.children}
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/src/components/FileTree/FileTree.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { useEditor } from "../../hooks/useEditor";
3 | import { View } from "../UI/View";
4 | import { FileFold } from "./FileFold";
5 |
6 | export const FileTree = () => {
7 | const run = useEditor((s) => s.run);
8 |
9 | return (
10 |
15 |
22 |
30 |
37 |
44 |
51 |
55 |
56 |
57 |
58 |
111 |
112 |
113 |
114 | );
115 | };
116 |
--------------------------------------------------------------------------------
/src/components/FileTree/index.ts:
--------------------------------------------------------------------------------
1 | export { FileTree } from "./FileTree";
2 |
--------------------------------------------------------------------------------
/src/components/Playground/GameView.tsx:
--------------------------------------------------------------------------------
1 | import { type FC, useEffect } from "react";
2 | import { useEditor } from "../../hooks/useEditor";
3 |
4 | export const GameView: FC = () => {
5 | const setRuntime = useEditor((state) => state.setRuntime);
6 |
7 | useEffect(() => {
8 | const iframe = document.getElementById(
9 | "game-view",
10 | ) as HTMLIFrameElement;
11 |
12 | const iframeWindow = iframe.contentWindow?.window;
13 | (window as any).iframeWindow = iframeWindow;
14 |
15 | setRuntime({ iframe: iframe, console: iframeWindow?.console });
16 | }, []);
17 |
18 | return (
19 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/Playground/LoadingPlayground.tsx:
--------------------------------------------------------------------------------
1 | import { type FC } from "react";
2 | import { cn } from "../../util/cn";
3 |
4 | type Props = {
5 | isLoading: boolean;
6 | isPortrait: boolean;
7 | isProject: boolean;
8 | };
9 |
10 | export const LoadingPlayground: FC = (props) => {
11 | return (
12 |
20 |
21 |
22 |
23 | Launching Playground...
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/Playground/Playground.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { useEffect, useState } from "react";
3 | import { useMediaQuery } from "react-responsive";
4 | import { Slide, ToastContainer } from "react-toastify";
5 | import { Tooltip } from "react-tooltip";
6 | import { useProject } from "../../features/Projects/stores/useProject";
7 | import { useConfig } from "../../hooks/useConfig";
8 | import { useEditor } from "../../hooks/useEditor.ts";
9 | import { decompressCode } from "../../util/compressCode";
10 | import { debug } from "../../util/logs";
11 | import { getPackageInfo } from "../../util/npm.ts";
12 | import { AboutDialog } from "../About";
13 | import ConfigDialog from "../Config/ConfigDialog";
14 | import { ProjectBrowser } from "../ProjectBrowser";
15 | import ExampleList from "../Toolbar/ExampleList";
16 | import { LoadingPlayground } from "./LoadingPlayground";
17 | import { WorkspaceExample } from "./WorkspaceExample";
18 | import { WorkspaceProject } from "./WorkspaceProject";
19 |
20 | const defaultTheme = localStorage.getItem("theme") as string;
21 | const browserPrefersDark = window.matchMedia(
22 | "(prefers-color-scheme: dark)",
23 | ).matches;
24 |
25 | document.documentElement.setAttribute(
26 | "data-theme",
27 | defaultTheme || (browserPrefersDark ? "Spiker" : "Ghostiny"),
28 | );
29 |
30 | localStorage.setItem(
31 | "theme",
32 | defaultTheme || (browserPrefersDark ? "Spiker" : "Ghostiny"),
33 | );
34 |
35 | const Playground = () => {
36 | const projectMode = useProject((state) => state.project.mode);
37 | const createNewProject = useProject((state) => state.createNewProject);
38 | const loadProject = useProject((state) => state.loadProject);
39 | const loadSharedDemo = useProject((state) => state.createFromShared);
40 | const setRuntime = useEditor((state) => state.setRuntime);
41 | const isPortrait = useMediaQuery({ query: "(orientation: portrait)" });
42 | const [loadingProject, setLoadingProject] = useState(true);
43 | const [loadingEditor, setLoadingEditor] = useState(true);
44 |
45 | const handleMount = () => {
46 | setLoadingEditor(false);
47 | };
48 |
49 | const loadShare = (sharedCode: string, sharedVersion?: string) => {
50 | debug(0, "[init] Importing shared code...", decompressCode(sharedCode));
51 | loadSharedDemo(decompressCode(sharedCode), sharedVersion);
52 | setLoadingProject(false);
53 | };
54 |
55 | const loadDemo = (demo: string) => {
56 | debug(0, "[init] Loading demo...", demo);
57 | createNewProject("ex", undefined, demo);
58 | setLoadingProject(false);
59 | };
60 |
61 | const loadNewProject = () => {
62 | debug(0, "[init] No project found, creating a new one...");
63 | createNewProject("pj");
64 | setLoadingProject(false);
65 | };
66 |
67 | const loadLastOpenedProject = (lastOpenedProjectId: string) => {
68 | debug(0, "[init] Loading last opened project...");
69 | loadProject(lastOpenedProjectId);
70 | setLoadingProject(false);
71 | };
72 |
73 | // First paint
74 | useEffect(() => {
75 | // Save in memory current versions
76 | getPackageInfo("kaplay").then((info) => {
77 | setRuntime({
78 | kaplayVersions: Object.keys(info.versions).reverse(),
79 | });
80 | });
81 |
82 | // Loading the project, default project, shared project, etc.
83 | const urlParams = new URLSearchParams(window.location.search);
84 | const lastOpenedPj = useConfig.getState().getConfig().lastOpenedProject;
85 | const sharedCode = urlParams.get("code");
86 | const sharedVersion = urlParams.get("version");
87 | const exampleName = urlParams.get("example");
88 |
89 | if (sharedCode) {
90 | loadShare(sharedCode, sharedVersion ?? undefined);
91 | } else if (exampleName) {
92 | loadDemo(exampleName);
93 | } else if (lastOpenedPj) {
94 | loadLastOpenedProject(lastOpenedPj);
95 | } else {
96 | loadNewProject();
97 | }
98 | }, []);
99 |
100 | if (loadingProject) {
101 | return (
102 |
107 | );
108 | }
109 |
110 | if (projectMode === "pj" && isPortrait) {
111 | return (
112 |
113 |

114 |
115 |
116 | Projects are currently not supported in mobile! Please use a
117 | desktop device, anyway you can still view demos.
118 |
119 |
120 |
121 |
122 |
123 | );
124 | }
125 |
126 | return (
127 | <>
128 | {projectMode === "pj"
129 | ? (
130 |
135 | )
136 | : (
137 |
142 | )}
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 | >
151 | );
152 | };
153 |
154 | export default Playground;
155 |
--------------------------------------------------------------------------------
/src/components/Playground/WorkspaceExample.tsx:
--------------------------------------------------------------------------------
1 | import { Allotment } from "allotment";
2 | import type { FC } from "react";
3 | import { allotmentStorage } from "../../util/allotmentStorage.ts";
4 | import { cn } from "../../util/cn";
5 | import { scrollbarSize } from "../../util/scrollbarSize.ts";
6 | import { AssetBrew } from "../AssetBrew/AssetBrew.tsx";
7 | import { ConsoleView } from "../ConsoleView/ConsoleView.tsx";
8 | import { MonacoEditor } from "../Editor/MonacoEditor";
9 | import { Toolbar } from "../Toolbar";
10 | import ExampleList from "../Toolbar/ExampleList";
11 | import ToolbarToolsMenu from "../Toolbar/ToolbarToolsMenu";
12 | import { GameView } from "./GameView";
13 |
14 | type Props = {
15 | editorIsLoading: boolean;
16 | isPortrait: boolean;
17 | onMount?: () => void;
18 | };
19 |
20 | export const WorkspaceExample: FC = (props) => {
21 | const { getAllotmentSize, setAllotmentSize } = allotmentStorage("example");
22 |
23 | const { scrollbarThinHeight } = scrollbarSize();
24 | const assetBrewHeight = 72 + scrollbarThinHeight();
25 |
26 | const handleDragStart = () =>
27 | document.documentElement.classList.toggle("select-none", true);
28 | const handleDragEnd = () =>
29 | document.documentElement.classList.toggle("select-none", false);
30 |
31 | return (
32 |
37 |
38 | {props.isPortrait && || }
39 |
40 |
41 |
42 | setAllotmentSize("editor", e)}
46 | onDragStart={handleDragStart}
47 | onDragEnd={handleDragEnd}
48 | >
49 |
50 | setAllotmentSize("brew", e)}
54 | onDragStart={handleDragStart}
55 | onDragEnd={handleDragEnd}
56 | className="p-px pt-0"
57 | >
58 |
59 |
62 |
63 |
70 |
71 |
72 |
73 |
74 |
75 | setAllotmentSize("console", e)}
79 | className="pr-px pb-px"
80 | >
81 |
82 |
83 |
84 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | {props.isPortrait && (
98 |
101 | )}
102 |
103 | );
104 | };
105 |
--------------------------------------------------------------------------------
/src/components/Playground/WorkspaceProject.tsx:
--------------------------------------------------------------------------------
1 | import { Allotment, LayoutPriority } from "allotment";
2 | import type { FC } from "react";
3 | import { allotmentStorage } from "../../util/allotmentStorage";
4 | import { cn } from "../../util/cn";
5 | import { Assets } from "../Assets";
6 | import { ConsoleView } from "../ConsoleView/ConsoleView.tsx";
7 | import { MonacoEditor } from "../Editor/MonacoEditor";
8 | import { FileTree } from "../FileTree";
9 | import { Toolbar } from "../Toolbar";
10 | import { GameView } from "./GameView";
11 |
12 | type Props = {
13 | editorIsLoading: boolean;
14 | isPortrait: boolean;
15 | onMount?: () => void;
16 | };
17 |
18 | export const WorkspaceProject: FC = (props) => {
19 | const { getAllotmentSize, setAllotmentSize } = allotmentStorage("project");
20 |
21 | const handleDragStart = () =>
22 | document.documentElement.classList.toggle("select-none", true);
23 | const handleDragEnd = () =>
24 | document.documentElement.classList.toggle("select-none", false);
25 |
26 | return (
27 | <>
28 |
36 |
39 |
40 |
41 | setAllotmentSize("editor", e)}
45 | onDragStart={handleDragStart}
46 | onDragEnd={handleDragEnd}
47 | className="p-px pt-0"
48 | >
49 |
56 |
57 |
58 |
59 | setAllotmentSize("brew", e)}
63 | onDragStart={handleDragStart}
64 | onDragEnd={handleDragEnd}
65 | className="pr-px"
66 | >
67 |
68 |
71 |
72 |
77 |
78 |
79 |
80 |
81 |
82 | setAllotmentSize("console", e)}
86 | className="pr-px pb-px"
87 | >
88 |
89 |
90 |
91 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 | >
105 | );
106 | };
107 |
--------------------------------------------------------------------------------
/src/components/ProjectBrowser/GroupBy.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from "react";
2 | import examplesData from "../../../kaplay/examples/examples.json";
3 | import { difficultyByName, type Example } from "../../data/demos";
4 | import type { ExamplesData } from "./ProjectBrowser";
5 |
6 | type Props = {
7 | value: string;
8 | onChange: (value: string) => void;
9 | options: string[];
10 | };
11 |
12 | const optionsMap: Record = {
13 | group: "Topic",
14 | };
15 |
16 | const groupMap: Record string> = {
17 | difficulty: (
18 | item,
19 | ) => (item.difficulty
20 | ? (examplesData as ExamplesData)?.difficulties?.[item.difficulty?.level]
21 | ?.displayName ?? ""
22 | : ""),
23 | };
24 |
25 | const sortables = ["category", "group", "difficulty"];
26 | const sortMap: Record number> = {
27 | category: (
28 | item,
29 | ) => ((examplesData as ExamplesData)?.categories?.[item]?.order ?? 0),
30 | difficulty: (item: string) => difficultyByName(item)?.level ?? 0,
31 | };
32 |
33 | export const groupBy = (entries: Example[], key: string) => {
34 | if (!key || key == "none") return { "all": entries };
35 |
36 | const grouped = entries.reduce(
37 | (arr: Record, entry: Example) => {
38 | const group = (groupMap?.[key]?.(entry)
39 | ?? (entry as Record)?.[key])
40 | || "uncategorized";
41 | arr[group] ??= [];
42 | arr[group].push(entry);
43 | return arr;
44 | },
45 | {},
46 | );
47 |
48 | if (!sortables.includes(key)) return grouped;
49 |
50 | return Object.fromEntries(
51 | Object.entries(grouped).sort(([a], [b]) => {
52 | if (a === "uncategorized") return 1;
53 | if (b === "uncategorized") return -1;
54 |
55 | return (sortMap?.[key]?.(a) ?? 0) - (sortMap?.[key]?.(b));
56 | }),
57 | );
58 | };
59 |
60 | export const GroupBy: FC = ({ value, onChange, options }) => {
61 | return (
62 |
63 |
64 | Group by
65 |
66 |
78 |
79 |
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/src/components/ProjectBrowser/ProjectBrowser.css:
--------------------------------------------------------------------------------
1 | .examples-list {
2 | display: grid;
3 | grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
4 | }
--------------------------------------------------------------------------------
/src/components/ProjectBrowser/ProjectCreate.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import type { FC } from "react";
3 | import type { ProjectMode } from "../../features/Projects/models/ProjectMode";
4 | import { useProject } from "../../features/Projects/stores/useProject";
5 |
6 | type Props = {
7 | mode: ProjectMode;
8 | tooltipContent?: string;
9 | };
10 |
11 | export const ProjectCreate: FC = ({ mode, tooltipContent }) => {
12 | const createNewProject = useProject((s) => s.createNewProject);
13 |
14 | const handleClick = () => {
15 | const dialog = document.querySelector(
16 | "#examples-browser",
17 | );
18 |
19 | if (!dialog?.open) return;
20 |
21 | if (mode === "pj") {
22 | createNewProject("pj");
23 | } else {
24 | createNewProject("ex");
25 | }
26 |
27 | dialog?.close();
28 | };
29 |
30 | return (
31 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/src/components/ProjectBrowser/ProjectEntry.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import * as ToggleGroup from "@radix-ui/react-toggle-group";
3 | import type { FC } from "react";
4 | import type { Tag } from "../../data/demos";
5 | import { useProject } from "../../features/Projects/stores/useProject";
6 | import { cn } from "../../util/cn";
7 |
8 | export type ProjectEntryProject = {
9 | key: string;
10 | name: string;
11 | formattedName: string;
12 | description: string | null;
13 | difficulty?: { level: number; name: string };
14 | tags?: Tag[];
15 | version: string;
16 | createdAt: string;
17 | updatedAt: string;
18 | };
19 |
20 | interface ProjectEntryProps {
21 | project: ProjectEntryProject;
22 | isProject?: boolean;
23 | toggleTag?: Function;
24 | }
25 |
26 | const imagesPerDifficulty = [
27 | assets.bean.outlined,
28 | assets.ghosty.outlined,
29 | assets.burpman.outlined,
30 | assets.ghostiny.outlined,
31 | ];
32 |
33 | const colorsPerDifficulty = [
34 | "text-primary",
35 | "text-warning",
36 | "text-error",
37 | "text-gray-400",
38 | ];
39 |
40 | export const ProjectEntry: FC = (
41 | { project, isProject, toggleTag },
42 | ) => {
43 | const createNewProject = useProject((s) => s.createNewProject);
44 | const loadProject = useProject((s) => s.loadProject);
45 | const projectKey = useProject((s) => s.projectKey);
46 | const demoKey = useProject((s) => s.demoKey);
47 | const isCurrent = projectKey == project.key || demoKey == project.key;
48 |
49 | const isRecent = (timestamp: string, withinDays = 5) =>
50 | Math.floor(
51 | (new Date().getTime() - new Date(timestamp).getTime())
52 | / (1000 * 60 * 60 * 24),
53 | ) <= withinDays;
54 |
55 | const isNew = isRecent(project.createdAt);
56 | const isUpdated = isRecent(project.updatedAt);
57 |
58 | const handleClick = () => {
59 | const dialog = document.querySelector(
60 | "#examples-browser",
61 | );
62 |
63 | if (!dialog?.open) return;
64 |
65 | if (isProject) {
66 | loadProject(project.key);
67 | } else {
68 | createNewProject("ex", {}, project.key);
69 | }
70 |
71 | dialog?.close();
72 | };
73 |
74 | return (
75 | e.key == "Enter" && handleClick()}
85 | tabIndex={0}
86 | role="button"
87 | >
88 |
89 | {!isProject && (isNew || isUpdated) && (
90 |
99 | {isNew ? "New" : "Updated"}
100 |
101 | )}
102 |
103 |
104 | {project.formattedName}
105 |
106 |
107 | {project.description && (
108 |
109 | {project.description}
110 |
111 | )}
112 |
113 |
114 | {project.difficulty && (
115 |
121 |
128 |
129 | {project.difficulty.name}
130 |
131 | )}
132 |
133 | {!!project?.tags?.length && (
134 | toggleTag
135 | ? (
136 |
140 | {project.tags?.map((tag) => (
141 | {
151 | e.stopPropagation();
152 | toggleTag(tag.name);
153 | }}
154 | >
155 | {tag?.displayName ?? tag.name}
156 |
157 | ))}
158 |
159 | )
160 | : (
161 |
162 | {project.tags?.map((tag) => (
163 |
172 | {tag?.displayName ?? tag.name}
173 |
174 | ))}
175 |
176 | )
177 | )}
178 |
179 |
180 |
181 | );
182 | };
183 |
--------------------------------------------------------------------------------
/src/components/ProjectBrowser/SortBy.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from "react";
2 | import type { Example } from "../../data/demos";
3 |
4 | type Props = {
5 | value: string;
6 | onChange: (value: string) => void;
7 | options: string[];
8 | };
9 |
10 | export const sortMapExamples: Record<
11 | string,
12 | (item: Example) => string | number
13 | > = {
14 | topic: item => item.sortName,
15 | title: item => item.formattedName,
16 | latest: item => new Date(item?.updatedAt || 0).getTime(),
17 | difficulty: item => item.difficulty?.level ?? 0,
18 | };
19 | export const sortMapProjects: Record<
20 | string,
21 | (item: Example) => string | number
22 | > = {
23 | latest: item => new Date(item?.updatedAt || 0).getTime(),
24 | type: item => item?.tags[0]?.name,
25 | title: item => item.name,
26 | };
27 |
28 | export const sortEntries = (
29 | value: Props["value"],
30 | type: string | "Projects" | "Examples",
31 | a: Example,
32 | b: Example,
33 | ): number => {
34 | const accessor = type == "Projects"
35 | ? sortMapProjects?.[value]
36 | : sortMapExamples?.[value];
37 |
38 | if (!accessor) return 0;
39 |
40 | const entryA = accessor(a);
41 | const entryB = accessor(b);
42 | const isNumeric = typeof entryA === "number" && typeof entryB === "number";
43 |
44 | if (value === "latest" && isNumeric) {
45 | return (entryB as number) - (entryA as number);
46 | }
47 |
48 | return String(entryA).localeCompare(String(entryB), undefined, {
49 | numeric: true,
50 | });
51 | };
52 |
53 | export const SortBy: FC = ({ value, onChange, options }) => {
54 | return (
55 |
56 |
57 | Sort by
58 |
59 |
70 |
71 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/src/components/ProjectBrowser/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ProjectBrowser";
2 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ExampleList.tsx:
--------------------------------------------------------------------------------
1 | import type { ChangeEvent, FC } from "react";
2 | import { demos } from "../../data/demos";
3 | import { useProject } from "../../features/Projects/stores/useProject";
4 | import { sortEntries } from "../ProjectBrowser/SortBy";
5 |
6 | const ExampleList: FC = () => {
7 | const getSavedProjects = useProject((s) => s.getSavedProjects);
8 | const getProjectMetadata = useProject((s) => s.getProjectMetadata);
9 | const loadProject = useProject((s) => s.loadProject);
10 | const createNewProject = useProject((s) => s.createNewProject);
11 | const projectKey = useProject((s) => s.projectKey || s.demoKey);
12 | useProject((s) => s.project.name);
13 |
14 | const handleExampleChange = (ev: ChangeEvent) => {
15 | const demoId = ev.target.selectedOptions[0].getAttribute(
16 | "data-demo-id",
17 | );
18 |
19 | if (demoId) {
20 | createNewProject("ex", {}, demoId);
21 | } else {
22 | loadProject(ev.target.value);
23 | }
24 | };
25 |
26 | const getSortedProjects = (mode: "pj" | "ex") => (
27 | getSavedProjects(mode)
28 | .map(pId => getProjectMetadata(pId))
29 | .sort((a, b) =>
30 | sortEntries(
31 | "latest",
32 | mode == "pj" ? "Projects" : "Examples",
33 | a,
34 | b,
35 | )
36 | )
37 | );
38 |
39 | return (
40 |
41 |
79 |
90 |
91 | );
92 | };
93 |
94 | export default ExampleList;
95 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ProjectStatus.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { useEffect, useRef, useState } from "react";
3 | import { useProject } from "../../features/Projects/stores/useProject";
4 | import { useEditor } from "../../hooks/useEditor.ts";
5 | import { cn } from "../../util/cn.ts";
6 |
7 | export const ProjectStatus = () => {
8 | const getSavedProjects = useProject((s) => s.getSavedProjects);
9 | const getProjectMetadata = useProject((s) => s.getProjectMetadata);
10 | const saveNewProject = useProject((s) => s.saveNewProject);
11 | const projectMode = useProject((s) => s.project.mode);
12 | const kaplayVersion = useProject((s) => s.project.kaplayVersion);
13 | const setProject = useProject((s) => s.setProject);
14 | const run = useEditor((s) => s.run);
15 | const kaplayVersions = useEditor((s) => s.runtime.kaplayVersions);
16 | const projectName = useProject((s) => s.project.name);
17 | const projectKey = useProject((s) => s.projectKey);
18 | const demoKey = useProject((s) => s.demoKey);
19 | const [isEditing, setIsEditing] = useState(false);
20 | const [initialName, setInitialName] = useState(() => projectName);
21 | // the name is the name of the project
22 | // the current value of the input that will be displayed
23 | const [inputValue, setInputValue] = useState(() => projectName);
24 | const nameInput = useRef(null);
25 | const [usedNames, setUsedNames] = useState(null);
26 | const [error, setError] = useState("");
27 |
28 | const setNameInputValue = (value: string) => {
29 | if (nameInput.current) {
30 | nameInput.current.value = value;
31 | }
32 | };
33 |
34 | const blur = () => {
35 | setTimeout(() => {
36 | nameInput.current?.blur();
37 | });
38 | };
39 |
40 | const isSaved = () => {
41 | return Boolean(projectKey);
42 | };
43 |
44 | const handleSaveProject = () => {
45 | if (!isSaved()) saveNewProject();
46 | };
47 |
48 | const setProjectName = (newName = initialName) => {
49 | if (newName == projectName) return;
50 | setProject({
51 | name: newName,
52 | });
53 | };
54 |
55 | const handleInputChange = (t: React.ChangeEvent) => {
56 | setInputValue(t.target.value || initialName);
57 | if (error) setError("");
58 | };
59 |
60 | const handleInputBlur = () => {
61 | if (!isEditing) return;
62 |
63 | if (!error) setInitialName(projectName);
64 |
65 | setIsEditing(false);
66 | };
67 |
68 | const resetValue = () => {
69 | setInputValue(initialName);
70 | setIsEditing(false);
71 | setNameInputValue(initialName);
72 | setError("");
73 | setTimeout(blur);
74 | };
75 |
76 | const isValid = () => {
77 | let names = usedNames;
78 | if (!names) {
79 | names = getSavedProjects()
80 | .filter(k => k !== projectKey)
81 | .map(k => getProjectMetadata(k).name);
82 | setUsedNames(names);
83 | }
84 | const nameAlreadyUsed = inputValue && names?.includes(inputValue);
85 | setError(
86 | nameAlreadyUsed ? "Project with that name already exists!" : "",
87 | );
88 | return !nameAlreadyUsed;
89 | };
90 |
91 | // Save project name
92 | useEffect(() => {
93 | const timeout = setTimeout(() => {
94 | if (inputValue == projectName) return;
95 | setProjectName(isValid() ? inputValue : initialName);
96 | }, 500);
97 | return () => clearTimeout(timeout);
98 | }, [inputValue]);
99 |
100 | // This is when a new project is loaded
101 | useEffect(() => {
102 | if (isEditing) return;
103 |
104 | setInitialName(projectName);
105 | setNameInputValue(projectName);
106 | setInputValue(projectName);
107 | setUsedNames(null);
108 | setError("");
109 | }, [projectKey, projectName]);
110 |
111 | return (
112 |
113 | {!demoKey && (
114 | <>
115 |
116 | {projectMode === "pj" ? "Project" : "Example"}
117 |
118 |
119 |
setIsEditing(true)}
134 | data-tooltip-id="global-open"
135 | data-tooltip-content={error}
136 | data-tooltip-hidden={!error}
137 | data-tooltip-variant="error"
138 | data-tooltip-place="bottom-start"
139 | onKeyUpCapture={e => {
140 | if (e.key === "Escape") resetValue();
141 | else if (e.key === "Enter" && !error) blur();
142 | }}
143 | >
144 |
145 | >
146 | )}
147 |
148 |
170 |
171 |
172 |
173 |
191 |
192 | );
193 | };
194 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ToolButtons/AboutButton.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { ToolbarButton } from "../ToolbarButton";
3 |
4 | export const AboutButton = () => {
5 | const handleModalOpenClick = () => {
6 | document.querySelector("#my_modal_1")
7 | ?.showModal();
8 | };
9 |
10 | return (
11 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ToolButtons/ConfigButton.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { ToolbarButton } from "../ToolbarButton";
3 |
4 | export const ConfigButton = () => {
5 | const handleModalOpenClick = () => {
6 | document.querySelector("#config")
7 | ?.showModal();
8 | };
9 |
10 | return (
11 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ToolButtons/ShareButton.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { toast } from "react-toastify";
3 | import { useProject } from "../../../features/Projects/stores/useProject";
4 | import { compressCode } from "../../../util/compressCode";
5 | import { ToolbarButton } from "../ToolbarButton";
6 |
7 | export const ShareButton = () => {
8 | const getMainFile = useProject((s) => s.getMainFile);
9 |
10 | const handleShare = () => {
11 | const demoKey = useProject.getState().demoKey;
12 | const kaplayVersion = useProject.getState().project.kaplayVersion;
13 |
14 | if (demoKey) {
15 | const exampleParam = encodeURIComponent(demoKey);
16 |
17 | const url = `${window.location.origin}/?example=${exampleParam}`;
18 |
19 | navigator.clipboard.writeText(url).then(() => {
20 | toast("Example shared, URL copied to clipboard!");
21 | });
22 |
23 | return;
24 | }
25 |
26 | const mainFile = getMainFile();
27 | const compressedCode = compressCode(mainFile?.value!);
28 | const codeParam = encodeURIComponent(compressedCode);
29 | const exampleVersion = encodeURIComponent(kaplayVersion);
30 | const url =
31 | `${window.location.origin}/?code=${codeParam}&version=${exampleVersion}`;
32 |
33 | if (url.length <= 2048) {
34 | navigator.clipboard.writeText(url).then(() => {
35 | toast("Project shared, URL copied to clipboard!");
36 | });
37 | } else {
38 | alert("Code too long to encode in URL");
39 | }
40 | };
41 |
42 | return (
43 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/src/components/Toolbar/Toolbar.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import ExampleList from "./ExampleList";
3 | import { ProjectStatus } from "./ProjectStatus";
4 | import ToolbarToolsMenu from "./ToolbarToolsMenu";
5 |
6 | export const Toolbar = () => {
7 | return (
8 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ToolbarButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from "react";
2 |
3 | type Props = {
4 | icon: string;
5 | text: string;
6 | tip?: string;
7 | keys?: string[];
8 | } & React.ButtonHTMLAttributes;
9 |
10 | type Ref = HTMLButtonElement;
11 |
12 | const generateKbdFromKeys = (keys: string[]) => {
13 | return keys.map((key) => `${key}`)
14 | .join(" + ");
15 | };
16 |
17 | export const ToolbarButton = forwardRef[((props, ref) => {
18 | return (
19 |
36 | );
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ToolbarDropdown.tsx:
--------------------------------------------------------------------------------
1 | import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
2 | import type { FC, PropsWithChildren } from "react";
3 | import { ToolbarButton } from "./ToolbarButton";
4 |
5 | interface ToolbarDropwdownProps extends PropsWithChildren {
6 | icon: string;
7 | tip: string;
8 | text: string;
9 | }
10 |
11 | export const ToolbarDropdown: FC = (
12 | { children, ...toolbarButtonProps },
13 | ) => {
14 | return (
15 |
16 |
17 |
21 |
22 |
23 |
27 | {children}
28 |
29 |
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ToolbarDropdownButton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DropdownMenuItem,
3 | DropdownMenuItemProps,
4 | } from "@radix-ui/react-dropdown-menu";
5 | import type { FC, PropsWithChildren } from "react";
6 |
7 | type ToolbarDropdownButtonProps = PropsWithChildren<
8 | DropdownMenuItemProps
9 | >;
10 |
11 | export const ToolbarDropdownButton: FC = ({
12 | children,
13 | ...props
14 | }) => {
15 | return (
16 |
20 | {children}
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ToolbarProjectDropdown.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import { type FC, useRef } from "react";
3 | import { buildProject } from "../../features/Projects/application/buildProject";
4 | import type { Asset } from "../../features/Projects/models/Asset";
5 | import type { File } from "../../features/Projects/models/File";
6 | import type { Project } from "../../features/Projects/models/Project";
7 | import { useProject } from "../../features/Projects/stores/useProject";
8 | import { useEditor } from "../../hooks/useEditor";
9 | import { downloadBlob } from "../../util/download";
10 | import { KDropdownMenuSeparator } from "../UI/KDropdown/KDropdownSeparator";
11 | import { ToolbarDropdown } from "./ToolbarDropdown";
12 | import { ToolbarDropdownButton } from "./ToolbarDropdownButton";
13 |
14 | export const ToolbarProjectDropdown: FC = () => {
15 | const run = useEditor((state) => state.run);
16 | const showNotification = useEditor((state) => state.showNotification);
17 | const createNewProject = useProject((state) => state.createNewProject);
18 | const newFileInput = useRef(null);
19 |
20 | const handleImport = () => {
21 | if (newFileInput.current) {
22 | newFileInput.current.click();
23 | }
24 | };
25 |
26 | const handleExport = () => {
27 | const { projectKey, project } = useProject.getState();
28 | const projectLocal = localStorage.getItem(projectKey ?? "");
29 |
30 | if (!projectLocal) {
31 | showNotification("No project to export... Remember to save!");
32 | return;
33 | }
34 |
35 | const blob = new Blob([projectLocal], {
36 | type: "application/json",
37 | });
38 |
39 | downloadBlob(blob, `${project.name.trim()}.kaplay`);
40 | showNotification("Downloading exported project...");
41 | };
42 |
43 | const handleHTMLBuild = async () => {
44 | const { project } = useProject.getState();
45 | const projectCode = await buildProject();
46 |
47 | if (!projectCode) {
48 | showNotification("Failed to export project as HTML");
49 | return;
50 | }
51 |
52 | const blob = new Blob([projectCode], {
53 | type: "text/html",
54 | });
55 |
56 | downloadBlob(blob, `${project.name.trim()}.html`);
57 | showNotification("Downloading HTML5 game...");
58 | };
59 |
60 | const handleProjectUpload = (e: React.ChangeEvent) => {
61 | const file = e.target.files?.[0];
62 | if (!file) return;
63 |
64 | const reader = new FileReader();
65 |
66 | reader.onload = (e) => {
67 | const project = JSON.parse(e.target?.result as string) as {
68 | state: {
69 | project: Omit & {
70 | assets: [string, Asset][];
71 | files: [string, File][];
72 | };
73 | };
74 | };
75 |
76 | const fileMap = new Map();
77 | const assetMap = new Map();
78 |
79 | project.state.project.files.forEach((file) => {
80 | fileMap.set(file[0], file[1]);
81 | });
82 |
83 | project.state.project.assets.forEach((asset) => {
84 | assetMap.set(asset[0], asset[1]);
85 | });
86 |
87 | createNewProject(project.state.project.mode, {
88 | ...project.state.project,
89 | files: fileMap,
90 | assets: assetMap,
91 | });
92 | };
93 |
94 | reader.readAsText(file);
95 | };
96 |
97 | const handleNewProject = () => {
98 | createNewProject("pj");
99 | run();
100 | };
101 |
102 | const handleNewExample = () => {
103 | createNewProject("ex");
104 | run();
105 | };
106 |
107 | return (
108 |
113 |
116 | Build (HTML5)
117 |
118 |
119 |
120 |
121 |
124 | Import
125 |
126 |
127 |
130 | Export
131 |
132 |
133 |
134 |
135 |
138 | Create new project
139 |
140 |
141 |
144 | Create new example
145 |
146 |
147 |
154 |
155 | );
156 | };
157 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ToolbarToolsMenu.tsx:
--------------------------------------------------------------------------------
1 | import { assets } from "@kaplayjs/crew";
2 | import type { FC, PropsWithChildren } from "react";
3 | import { useProject } from "../../features/Projects/stores/useProject";
4 | import { useEditor } from "../../hooks/useEditor";
5 | import { ToolbarButton } from "./ToolbarButton";
6 | import { ToolbarProjectDropdown } from "./ToolbarProjectDropdown";
7 | import { AboutButton } from "./ToolButtons/AboutButton";
8 | import { ConfigButton } from "./ToolButtons/ConfigButton";
9 | import { ShareButton } from "./ToolButtons/ShareButton";
10 |
11 | const ToolbarToolItem: FC = ({ children }) => {
12 | return (
13 |
14 | {children}
15 |
16 | );
17 | };
18 |
19 | const ToolbarToolsMenu: FC = () => {
20 | const projectMode = useProject((state) => state.project.mode);
21 | const run = useEditor((state) => state.run);
22 |
23 | return (
24 | ]
25 |
26 |
33 |
34 |
35 | {projectMode == "ex" && (
36 |
37 |
38 |
39 | )}
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default ToolbarToolsMenu;
57 |
--------------------------------------------------------------------------------
/src/components/Toolbar/index.ts:
--------------------------------------------------------------------------------
1 | export { Toolbar } from "./Toolbar";
2 |
--------------------------------------------------------------------------------
/src/components/UI/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps, FC } from "react";
2 |
3 | interface DialogProps extends ComponentProps<"dialog"> {
4 | onSave?: () => void;
5 | onCloseWithoutSave?: () => void;
6 | }
7 |
8 | export const Dialog: FC = (
9 | { onSave, onCloseWithoutSave, ...props },
10 | ) => {
11 | return (
12 |
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/src/components/UI/KDropdown/KDropdownSeparator.tsx:
--------------------------------------------------------------------------------
1 | import { DropdownMenuSeparator } from "@radix-ui/react-dropdown-menu";
2 | import { type ComponentProps, type FC, forwardRef } from "react";
3 |
4 | type DropdownSeparatorProps = ComponentProps;
5 |
6 | export const KDropdownMenuSeparator: FC = forwardRef((
7 | { children, ...props },
8 | ref,
9 | ) => {
10 | return (
11 |
16 | );
17 | });
18 |
--------------------------------------------------------------------------------
/src/components/UI/TabTrigger.tsx:
--------------------------------------------------------------------------------
1 | import * as Tabs from "@radix-ui/react-tabs";
2 | import type { FC } from "react";
3 |
4 | export type TabTriggerProps = {
5 | label: string;
6 | value: string;
7 | icon: string;
8 | count?: number;
9 | };
10 |
11 | export const TabTrigger: FC = (
12 | { label, icon, value, count },
13 | ) => {
14 | return (
15 |
19 |
20 |

25 |
26 |
{label}
27 |
28 | {!!count && (
29 |
30 | {count}
31 |
32 | )}
33 |
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/UI/TabsList.tsx:
--------------------------------------------------------------------------------
1 | import * as Tabs from "@radix-ui/react-tabs";
2 | import type { FC, PropsWithChildren } from "react";
3 | import { cn } from "../../util/cn";
4 |
5 | export type TabsListProps = PropsWithChildren<
6 | {
7 | className?: string;
8 | }
9 | >;
10 |
11 | export const TabsList: FC = (props) => {
12 | const {
13 | children,
14 | className,
15 | } = props;
16 |
17 | return (
18 |
24 | {children}
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/UI/View.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps, ElementType, FC, PropsWithChildren } from "react";
2 | import { cn } from "../../util/cn";
3 |
4 | export type ViewProps = PropsWithChildren<
5 | {
6 | direction?: "row" | "column";
7 | gap?: 1.5 | 2 | 4 | 8;
8 | padding?: 2 | 4 | 8;
9 | justify?: "center" | "start" | "end" | "between" | "around";
10 | height?: "full" | "screen";
11 | rounded?: "xl" | "lg" | "md" | "sm";
12 | cursor?: "pointer" | "default";
13 | className?: string;
14 | id?: string;
15 | el: ElementType<
16 | {
17 | className?: string;
18 | } & ComponentProps<"div">
19 | >;
20 | } & ComponentProps<"div">
21 | >;
22 |
23 | export const View: FC = (props) => {
24 | const {
25 | direction,
26 | gap,
27 | justify,
28 | children,
29 | el,
30 | className,
31 | cursor,
32 | height,
33 | padding,
34 | rounded,
35 | ...rest
36 | } = props;
37 |
38 | const needsFlex = direction || gap || justify;
39 |
40 | return (
41 |
70 | {children}
71 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/src/config/common.ts:
--------------------------------------------------------------------------------
1 | import packageJson from "../../package.json";
2 |
3 | export const VERSION = packageJson.version;
4 | export const CHANGELOG =
5 | "https://github.com/lajbel/kaplayground/blob/master/CHANGELOG.md";
6 | export const REPO = "https://github.com/kaplayjs/kaplayground";
7 |
--------------------------------------------------------------------------------
/src/data/demos.ts:
--------------------------------------------------------------------------------
1 | import {
2 | difficulties as difficultiesData,
3 | tags,
4 | } from "../../kaplay/examples/examples.json";
5 | import examplesList from "./exampleList.json";
6 |
7 | export type ExamplesDataRecord = Record;
12 |
13 | export type Tag = {
14 | name: string;
15 | } & ExamplesDataRecord[string];
16 |
17 | export type Example = {
18 | key: string;
19 | name: string;
20 | formattedName: string;
21 | sortName: string;
22 | category: string;
23 | group: string;
24 | description: string | null;
25 | code: string;
26 | version: string;
27 | minVersion: string;
28 | tags: Tag[];
29 | difficulty?: {
30 | level: number;
31 | name: string;
32 | };
33 | createdAt: string;
34 | updatedAt: string;
35 | locked?: boolean;
36 | };
37 |
38 | export const difficulties = [
39 | ...difficultiesData.map(({ displayName }, index: number) => ({
40 | level: index,
41 | name: displayName,
42 | })),
43 | {
44 | level: difficultiesData.length,
45 | name: "Unknown",
46 | },
47 | ];
48 |
49 | export const difficultyByName = (name: string) =>
50 | difficulties.find(d => d.name === name);
51 |
52 | export const demos = examplesList.map((example) => {
53 | const obj: Example = {
54 | ...example,
55 | tags: example.tags.map(tag => ({
56 | name: tag,
57 | ...(tags as ExamplesDataRecord)?.[tag],
58 | })),
59 | difficulty: difficulties[example.difficulty]
60 | ?? difficulties[difficulties.length - 1],
61 | key: example.name,
62 | };
63 |
64 | return obj;
65 | });
66 |
--------------------------------------------------------------------------------
/src/features/Editor/application/insertAfterCursor.ts:
--------------------------------------------------------------------------------
1 | import { useEditor } from "../../../hooks/useEditor";
2 |
3 | export const insertAfterCursor = (text: string) => {
4 | const monacoEditor = useEditor.getState().runtime.editor;
5 |
6 | if (monacoEditor) {
7 | const model = monacoEditor.getModel();
8 | const selection = monacoEditor.getSelection();
9 | const position = selection?.getPosition();
10 |
11 | if (model && position) {
12 | const newText = model.getValueInRange({
13 | startLineNumber: position.lineNumber,
14 | startColumn: position.column,
15 | endLineNumber: position.lineNumber,
16 | endColumn: position.column,
17 | }) + text;
18 |
19 | model.pushEditOperations(
20 | [],
21 | [
22 | {
23 | range: {
24 | startLineNumber: position.lineNumber,
25 | startColumn: position.column,
26 | endLineNumber: position.lineNumber,
27 | endColumn: position.column,
28 | },
29 | text: newText,
30 | forceMoveMarkers: true,
31 | },
32 | ],
33 | () => null,
34 | );
35 | }
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/src/features/Projects/application/buildCode.ts:
--------------------------------------------------------------------------------
1 | import * as esbuild from "esbuild-wasm";
2 | import { useProject } from "../stores/useProject";
3 |
4 | await esbuild.initialize({
5 | wasmURL: "https://unpkg.com/esbuild-wasm/esbuild.wasm",
6 | worker: true,
7 | });
8 |
9 | const virtualPlugin: esbuild.Plugin = {
10 | name: "virtual-fs",
11 | setup(build) {
12 | build.onResolve({ filter: /.*/ }, args => {
13 | const resolvedPath =
14 | new URL(args.path, "file://" + args.resolveDir + "/").pathname;
15 |
16 | return { path: resolvedPath, namespace: "virtual" };
17 | });
18 |
19 | build.onLoad({ filter: /.*/ }, async args => {
20 | const path = args.path.startsWith("/")
21 | ? args.path.slice(1)
22 | : args.path;
23 |
24 | const file = useProject.getState().getFile(path);
25 |
26 | if (!file) throw new Error(`File not found: ${path}`);
27 |
28 | const loader = path.endsWith(".ts") ? "ts" : "js";
29 |
30 | return { contents: file.value, loader };
31 | });
32 | },
33 | };
34 |
35 | /**
36 | * Build code using esbuild.
37 | *
38 | * @returns - The built code as a string.
39 | */
40 | export async function buildCode() {
41 | const result = await esbuild.build({
42 | entryPoints: ["/main.js"],
43 | bundle: true,
44 | write: false,
45 | plugins: [virtualPlugin],
46 | format: "esm",
47 | target: "esnext",
48 | });
49 |
50 | const buildResult = result.outputFiles;
51 | const decoder = new TextDecoder("utf-8");
52 | const fileContentsAsString = decoder.decode(buildResult[0].contents);
53 |
54 | return fileContentsAsString;
55 | }
56 |
--------------------------------------------------------------------------------
/src/features/Projects/application/buildProject.ts:
--------------------------------------------------------------------------------
1 | import { getVersion, parseAssets } from "../../../util/compiler";
2 | import { wrapCode } from "./wrapCode";
3 |
4 | const toDataUrl = (data: string) => {
5 | const base64 = btoa(data);
6 | return `data:text/javascript;base64,${base64}`;
7 | };
8 |
9 | export async function buildProject() {
10 | const code = await wrapCode();
11 | const kaplayLib = await getVersion(true);
12 |
13 | if (!kaplayLib) {
14 | throw new Error("Failed to fetch the library");
15 | }
16 |
17 | const kaplayLibDataUrl = toDataUrl(kaplayLib);
18 |
19 | console.log("kaplayLib", kaplayLibDataUrl);
20 | console.log("code", code);
21 |
22 | const projectCode = `
23 |
24 |
25 |
26 |
27 | Game Preview
28 |
46 |
47 |
48 |
49 |
53 |
54 |