├── .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 | 7 |
8 |
9 | KAPLAY 14 | 15 |
16 | 17 |
18 |

19 | KAPLAYGROUND is a web editor designed for creating 20 | KAPLAY games. 21 |

22 | 23 |
24 |
25 | 26 | 27 | Version: 28 | 29 | 30 | Ver.: 31 | 32 | {VERSION} 33 | 34 | 35 | 40 | KAPLAYGROUND 45 | 46 | KAPLAY Docs 47 | 48 |
49 | 50 |
51 | 52 | 81 |
82 |
83 | 84 |
85 |
86 |
87 | 90 |
91 |
92 |
93 |
94 | 95 |
96 | 97 |
98 |
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} 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 | {`Asset 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 |
    71 |
    72 | 76 | 81 |
    82 |
    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 | {label} 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 | 25 | 26 | 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 | {file.kind} 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 |