Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ jobs:
run: pnpm test
- name: Type checks
run: pnpm typecheck
- name: Build the web app, the VS Code extension and the desktop app
- name: Build the web app, the VS Code extension, the desktop app and the MCP server
run: |
pnpm --filter @oxygen/web build
pnpm --filter oxygen-particle-editor build
pnpm --filter @oxygen/desktop build
pnpm --filter @oxygen/mcp build
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,20 @@
### Added

- The web app is online at [oxygen.hydrogen.exposed](https://oxygen.hydrogen.exposed).
- An MCP server for coding agents (`apps/mcp`): agents can look up the particle format and the game's measured
behaviour, create effects from the starting effect or an example, edit them by JSON path without disturbing
formatting or rewrite them whole, check them, simulate them and render previews with Oxygen's viewport, with
other effects playing alongside, and open them in the desktop app for you. See
[Oxygen for coding agents](https://github.com/dev-hydrogen/oxygen/blob/main/docs/agents.md).
- The desktop app keeps one window: opening a file while it runs (from the command line, a file manager or an
agent) opens it there, or brings its tab forward.

### Fixed

- A paused preview drew effects that count particles in their render expression (spiral galaxy, aurora,
lightning, magic circle and others) wrong: every redraw advanced the count, so colours and sizes drifted.
Redrawing a paused frame, or one scrubbed to, now shows it as it played.

## Oxygen 0.1.0

The first release of Oxygen, a ground-up rewrite of [Snowstorm](https://github.com/JannisX11/snowstorm) 3.2.2.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ menu) opens one of the bundled examples as a new file you can save into your pac
files or a whole resource pack folder. The desktop app can also deploy your pack to Minecraft and spawn the
effect in your world: run `/connect localhost:19131` in the game once, then use the Game menu.

**Coding agents.** An MCP server lets agents such as Claude Code create, edit, check and preview effects with
Oxygen's engine. See [Oxygen for coding agents](docs/agents.md).

New to the editor? The [user guide](docs/guide.md) walks through the panels and the usual tasks.

## Layouts
Expand Down Expand Up @@ -76,6 +79,7 @@ pnpm dev # the web app at http://localhost:5178
| Web app | `pnpm --filter @oxygen/web build` | `apps/web/dist/`, a static site |
| VS Code extension | `pnpm package:vscode` | `apps/vscode/oxygen-particle-editor-<version>.vsix` |
| Desktop app | `pnpm package:desktop` | an installer in `apps/desktop/release/` |
| MCP server for coding agents | `pnpm --filter @oxygen/mcp build` | `apps/mcp/dist/server.cjs` |

Tests, the repository layout and the in-game test pack are covered in [building and development](docs/building.md).
What changed from Snowstorm is in the [changelog](CHANGELOG.md).
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/renderer/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mountOxygen, type FileRef, type Host } from '@oxygen/ui';
import type { DesktopApi } from '../src/api';
import type { DesktopApi, DesktopFile } from '../src/api';

/** Oxygen in Electron: native files, pack folders, outside-change sync and the game link */
const desk = (window as unknown as { oxygenDesktop: DesktopApi }).oxygenDesktop;
Expand Down Expand Up @@ -97,10 +97,13 @@ desk.onMenu((cmd) => {
else if (cmd === 'openPack') void w.openPack();
});

// reopen what was open last time (or files passed on the command line); otherwise the start screen
void desk.openedAtStart().then((files) => {
// reopen what was open last time (or files passed on the command line); otherwise the start screen. Files opened
// from outside later arrive the same way; one already open gets its tab focused.
const openAll = (files: DesktopFile[]) => {
for (const f of files) {
desk.watch(f.path);
w.open({ file: { name: f.name, id: f.path }, text: f.text });
}
});
};
desk.onOpenRequested(openAll);
void desk.openedAtStart().then(openAll);
2 changes: 2 additions & 0 deletions apps/desktop/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface DesktopApi {
setOpenFiles(paths: string[]): void;
watch(path: string): void;
onExternalChange(cb: (path: string, text: string) => void): void;
/** files opened from outside while Oxygen runs (Oxygen started again with them, or handed over by the OS) */
onOpenRequested(cb: (files: DesktopFile[]) => void): void;
onMenu(cb: (command: string) => void): void;
settings(): Promise<Record<string, unknown>>;
saveSettings(s: Record<string, unknown>): void;
Expand Down
78 changes: 61 additions & 17 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,37 @@ function readDesktopFile(path: string): DesktopFile {
return { path, name: basename(path), text: readFileSync(path, 'utf8') };
}

/** Particle files named on a command line, resolved against the folder it ran in */
function filesFromArgs(argv: string[], cwd: string): string[] {
return argv
.slice(app.isPackaged ? 1 : 2)
.filter((a) => a.endsWith('.json'))
.map((a) => resolve(cwd, a))
.filter((a) => existsSync(a));
}

// ---- files opened from outside while Oxygen runs (a file manager, the command line, a coding agent)
/** files that arrived before the window asked for its start files */
let queued: string[] = [];
let rendererStarted = false;
function openRequested(paths: string[]) {
if (!win) {
queued.push(...paths);
if (app.isReady()) createWindow();
return;
}
// a window still loading picks the files up with its start files, and shows itself when ready
if (!rendererStarted) {
queued.push(...paths);
return;
}
if (paths.length) win.webContents.send('openRequested', paths.map(readDesktopFile));
if (win.isMinimized()) win.restore();
win.show();
if (process.platform === 'darwin') app.focus({ steal: true });
else win.focus();
}

// ---- file watching: outside changes flow into the open document; our own saves are ignored
const watchers = new Map<string, FSWatcher>();
const lastText = new Map<string, string>();
Expand Down Expand Up @@ -247,10 +278,12 @@ function registerIpc() {
.map((f) => ({ name: f.replace('.particle.json', ''), text: readFileSync(join(dir, f), 'utf8') }));
});
ipcMain.handle('openedAtStart', () => {
const args = process.argv.slice(app.isPackaged ? 1 : 2).filter((a) => a.endsWith('.json') && existsSync(a));
rendererStarted = true;
const args = [...filesFromArgs(process.argv, process.cwd()), ...queued];
queued = [];
// what was open when Oxygen last closed (closed tabs stay closed)
const last = ((settings.openFiles as string[]) ?? []).filter((p) => existsSync(p));
return [...new Set([...args.map((a) => resolve(a)), ...last])].map(readDesktopFile);
return [...new Set([...args, ...last])].map(readDesktopFile);
});
ipcMain.on('watch', (_e, path: string) => {
watchFile(path);
Expand Down Expand Up @@ -318,6 +351,7 @@ function buildMenu() {
}

function createWindow() {
rendererStarted = false;
win = new BrowserWindow({
width: 1480,
height: 940,
Expand All @@ -339,20 +373,30 @@ function createWindow() {
win.on('closed', () => (win = null));
}

app.whenReady().then(() => {
loadSettings();
registerIpc();
buildMenu();
// a taken port moves on to the next free one; the Game menu shows which (or why none)
void connect.start().then(() => win?.webContents.send('gameStatus', gameStatus()));
setInterval(pollContentLog, 1500);
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
// one Oxygen at a time: started again (with files or not), it hands its command line to the running app and quits
if (!app.requestSingleInstanceLock()) app.quit();
else {
app.on('second-instance', (_e, argv, cwd) => openRequested(filesFromArgs(argv, cwd)));
// macOS hands files over by event (Finder, `open -a Oxygen file`), at launch before the app is ready too
app.on('open-file', (e, path) => {
e.preventDefault();
openRequested([path]);
});
app.whenReady().then(() => {
loadSettings();
registerIpc();
buildMenu();
// a taken port moves on to the next free one; the Game menu shows which (or why none)
void connect.start().then(() => win?.webContents.send('gameStatus', gameStatus()));
setInterval(pollContentLog, 1500);
if (!win) createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
});

app.on('window-all-closed', () => {
connect.stop();
if (process.platform !== 'darwin') app.quit();
});
app.on('window-all-closed', () => {
connect.stop();
if (process.platform !== 'darwin') app.quit();
});
}
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const api: DesktopApi = {
setOpenFiles: (paths) => ipcRenderer.send('openSet', paths),
watch: (path) => ipcRenderer.send('watch', path),
onExternalChange: (cb) => on('externalChange', cb),
onOpenRequested: (cb) => on('openRequested', cb),
onMenu: (cb) => on('menu', cb),
settings: () => ipcRenderer.invoke('settings'),
saveSettings: (s) => ipcRenderer.send('saveSettings', s),
Expand Down
43 changes: 43 additions & 0 deletions apps/mcp/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Builds the MCP server (esbuild → dist/server.cjs) and the headless preview page (Vite → dist/preview), and copies
// what they read at run time: the examples, the vanilla sprites and Oxygen's guides.
import { svelte } from '@sveltejs/vite-plugin-svelte';
import * as esbuild from 'esbuild';
import { chmodSync, cpSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build as viteBuild } from 'vite';

const here = dirname(fileURLToPath(import.meta.url));
rmSync(resolve(here, 'dist'), { recursive: true, force: true });

cpSync(resolve(here, '../../packages/ui/static/examples'), resolve(here, 'dist/examples'), { recursive: true });
cpSync(resolve(here, '../../vendor/wintersky/assets'), resolve(here, 'dist/vanilla'), { recursive: true });
mkdirSync(resolve(here, 'dist/docs'), { recursive: true });
for (const doc of ['accuracy.md', 'molang.md']) cpSync(resolve(here, '../../docs', doc), resolve(here, 'dist/docs', doc));

await esbuild.build({
entryPoints: [resolve(here, 'src/server.ts')],
outfile: resolve(here, 'dist/server.cjs'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node22',
// Playwright finds its browsers relative to its own install
external: ['playwright'],
// prefer ESM builds: jsonc-parser's UMD build hides its requires from the bundler
mainFields: ['module', 'main'],
banner: { js: '#!/usr/bin/env node' },
sourcemap: true,
logLevel: 'warning',
});
chmodSync(resolve(here, 'dist/server.cjs'), 0o755);

await viteBuild({
root: resolve(here, 'preview'),
base: './',
configFile: false,
plugins: [svelte()],
logLevel: 'warn',
build: { outDir: resolve(here, 'dist/preview'), emptyOutDir: true, target: 'es2022', chunkSizeWarningLimit: 5000, modulePreload: false },
});
console.log('mcp built');
40 changes: 40 additions & 0 deletions apps/mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@oxygen/mcp",
"version": "0.1.1",
"private": true,
"type": "module",
"description": "Oxygen for coding agents: an MCP server that creates, edits, checks, simulates and renders Bedrock particle effects",
"license": "GPL-3.0-or-later",
"bin": {
"oxygen-mcp": "./dist/server.cjs"
},
"scripts": {
"build": "node build.mjs",
"start": "node dist/server.cjs",
"typecheck": "tsc --noEmit -p . && svelte-check --tsconfig ./preview/tsconfig.json"
},
"dependencies": {
"playwright": "1.64.1"
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.32.1",
"@oxygen/effect": "workspace:*",
"@oxygen/format": "workspace:*",
"@oxygen/lang": "workspace:*",
"@oxygen/metadata": "workspace:*",
"@oxygen/ui": "workspace:*",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@types/three": "^0.186.0",
"@types/node": "^24.0.0",
"esbuild": "^0.28.2",
"jsonc-parser": "^3.3.1",
"molangjs": "workspace:*",
"svelte": "^5.57.2",
"svelte-check": "^4.7.7",
"three": "^0.186.1",
"typescript": "6.0.3",
"vite": "^8.3.4",
"wintersky": "workspace:*",
"zod": "^4.0.0"
}
}
19 changes: 19 additions & 0 deletions apps/mcp/preview/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Oxygen headless preview</title>
<style>
body {
margin: 0;
background: #000;
}
canvas {
display: block;
}
</style>
</head>
<body>
<script type="module" src="./main.ts"></script>
</body>
</html>
Loading
Loading