From d71eade81649a3a59b440614363707c470e01f21 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 29 Mar 2026 09:24:20 +0200 Subject: [PATCH] feat(manavoxel): add item programming system (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Property Panel (Slider Ebene 1): - Sliders for damage, range, speed, durability - Element selector (fire, ice, poison, lightning, neutral) - Rarity selector (common → legendary) with color coding - Sound and particle effect dropdowns - Auto-save on any change, opens via double-click on inventory item Trigger-Action Editor (Ebene 2): - WHEN [trigger] THEN [action] visual rule builder - 10 triggers: onTouch, onUse, onTimer, onHpBelow, onNearItem, etc. - 15 actions: damage, heal, particle, sound, pixel destroy, teleport, message, variable, event, camera shake, wait, etc. - Per-action parameter inputs (amount, radius, text, etc.) - Add/remove rules and actions, readable preview text - Tab switching between Properties and Behaviors panels Particle System: - 7 presets: sparks, fire_burst, ice_shards, poison_cloud, lightning_bolt, heal_glow, shatter - Physics-based (velocity, gravity, lifetime, alpha fade) - Item use (Space key) spawns particles in facing direction - High-damage items destroy pixels in facing direction (radius scaling) - Cooldown system for item use Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/components/Inventory.svelte | 9 +- .../web/src/lib/editor/property-panel.svelte | 221 +++++++++++++ .../web/src/lib/editor/trigger-editor.svelte | 306 ++++++++++++++++++ .../manavoxel/apps/web/src/lib/engine/game.ts | 53 +++ .../apps/web/src/lib/engine/particles.ts | 171 ++++++++++ .../apps/web/src/routes/+page.svelte | 71 +++- 6 files changed, 829 insertions(+), 2 deletions(-) create mode 100644 apps/manavoxel/apps/web/src/lib/editor/property-panel.svelte create mode 100644 apps/manavoxel/apps/web/src/lib/editor/trigger-editor.svelte create mode 100644 apps/manavoxel/apps/web/src/lib/engine/particles.ts diff --git a/apps/manavoxel/apps/web/src/lib/components/Inventory.svelte b/apps/manavoxel/apps/web/src/lib/components/Inventory.svelte index 1af5ea77c..2a066d18c 100644 --- a/apps/manavoxel/apps/web/src/lib/components/Inventory.svelte +++ b/apps/manavoxel/apps/web/src/lib/components/Inventory.svelte @@ -1,7 +1,11 @@ + +
+
+

Item Properties

+ {#if onClose} + + {/if} +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ {#each elements as el} + + {/each} +
+
+ + +
+ +
+ {#each rarities as r} + + {/each} +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ {itemName} | {element} | {damage} dmg | {rarity} +
+
diff --git a/apps/manavoxel/apps/web/src/lib/editor/trigger-editor.svelte b/apps/manavoxel/apps/web/src/lib/editor/trigger-editor.svelte new file mode 100644 index 000000000..ac565672a --- /dev/null +++ b/apps/manavoxel/apps/web/src/lib/editor/trigger-editor.svelte @@ -0,0 +1,306 @@ + + +
+
+

Behaviors

+
+ + {#if onClose} + + {/if} +
+
+ + {#if rules.length === 0} +
+ No behaviors yet. Click "+ Rule" to add one. +
+ {/if} + +
+ {#each rules as rule, ri} +
+ +
+ WHEN + + +
+ + + {#if rule.trigger.type === 'onTimer'} +
+ every + setParam(rule.trigger.params, 'seconds', e.currentTarget.value)} + class="w-12 rounded bg-gray-700 px-1 py-0.5 text-xs text-white outline-none" + min="0.1" + step="0.5" + /> + sec +
+ {/if} + {#if rule.trigger.type === 'onHpBelow'} +
+ HP below + setParam(rule.trigger.params, 'threshold', e.currentTarget.value)} + class="w-12 rounded bg-gray-700 px-1 py-0.5 text-xs text-white outline-none" + /> +
+ {/if} + + + {#each rule.actions as action, ai} +
+ + {ai === 0 ? 'THEN' : 'AND'} + + + +
+ + +
+ {#if action.type === 'damage' || action.type === 'heal'} + setParam(action.params, 'amount', e.currentTarget.value)} + class="w-12 rounded bg-gray-700 px-1 py-0.5 text-xs text-white outline-none" + placeholder="amt" + /> + {/if} + {#if action.type === 'particle'} + + {/if} + {#if action.type === 'sound'} + + {/if} + {#if action.type === 'deletePixel' || action.type === 'setPixel'} + setParam(action.params, 'radius', e.currentTarget.value)} + class="w-12 rounded bg-gray-700 px-1 py-0.5 text-xs text-white outline-none" + placeholder="radius" + min="1" + max="10" + /> + {/if} + {#if action.type === 'message'} + setParam(action.params, 'text', e.currentTarget.value)} + class="w-full rounded bg-gray-700 px-1 py-0.5 text-xs text-white outline-none" + placeholder="Message text..." + /> + {/if} + {#if action.type === 'wait'} + setParam(action.params, 'seconds', e.currentTarget.value)} + class="w-12 rounded bg-gray-700 px-1 py-0.5 text-xs text-white outline-none" + placeholder="sec" + min="0.1" + step="0.1" + /> + {/if} + {#if action.type === 'cameraShake'} + setParam(action.params, 'intensity', e.currentTarget.value)} + class="w-12 rounded bg-gray-700 px-1 py-0.5 text-xs text-white outline-none" + placeholder="1-10" + min="1" + max="10" + /> + {/if} +
+ {/each} + + +
+ {/each} +
+ + + {#if rules.length > 0} +
+
Preview:
+ {#each rules as rule} +
+ WHEN + {triggerTypes.find((t) => t.value === rule.trigger.type)?.label ?? rule.trigger.type} + {#each rule.actions as action, i} + {i === 0 ? ' THEN ' : ' AND '} + {actionTypes.find((a) => a.value === action.type)?.label ?? action.type} + {#if action.params.amount}({action.params.amount}){/if} + {#if action.params.type}({action.params.type}){/if} + {#if action.params.text}("{action.params.text}"){/if} + {/each} +
+ {/each} +
+ {/if} +
diff --git a/apps/manavoxel/apps/web/src/lib/engine/game.ts b/apps/manavoxel/apps/web/src/lib/engine/game.ts index 547a3d8c8..840cc6550 100644 --- a/apps/manavoxel/apps/web/src/lib/engine/game.ts +++ b/apps/manavoxel/apps/web/src/lib/engine/game.ts @@ -3,9 +3,11 @@ import { Camera } from './camera'; import { InputManager } from './input'; import { TilemapRenderer } from './tilemap'; import { Player } from './player'; +import { ParticleSystem } from './particles'; import { AreaManager, generateDemoStreet, generateDemoInterior } from './area-manager'; import { UndoStack, brushStroke, floodFill, pipette, type ToolType } from '$lib/editor/tools'; import { DEFAULT_MATERIALS, MATERIAL_AIR, type Material } from '@manavoxel/shared'; +import type { Inventory } from './inventory'; export class GameEngine { app: Application; @@ -15,11 +17,14 @@ export class GameEngine { player: Player | null = null; undo: UndoStack; areaManager: AreaManager; + particles: ParticleSystem; + inventory: Inventory | null = null; private _container: HTMLDivElement; private _worldContainer: Container; private _fadeOverlay: Graphics; private _initialized = false; + private _useItemCooldown = 0; // Editor state private _editing = false; @@ -71,6 +76,7 @@ export class GameEngine { this.camera = new Camera(this._worldContainer); this.input = new InputManager(container); this.areaManager = new AreaManager(this._worldContainer); + this.particles = new ParticleSystem(this._worldContainer); this._init(); } @@ -167,6 +173,12 @@ export class GameEngine { this.undo.redo(this.tilemap); } + // Update particles + this.particles.update(); + + // Cooldown tick + if (this._useItemCooldown > 0) this._useItemCooldown--; + this.camera.update(this.app.screen.width, this.app.screen.height); } @@ -207,6 +219,47 @@ export class GameEngine { this.onStateChange?.(); } + // Use held item (Space key) + if (this.input.isKeyDown('Space') && this._useItemCooldown <= 0 && this.inventory) { + const heldItem = this.inventory.heldItem; + if (heldItem && heldItem.properties.damage > 0) { + this._useItemCooldown = 15; // ~0.25s cooldown + + // Spawn particle effect at player facing direction + const dirOffsets = [ + { dx: 0, dy: -20 }, // up + { dx: 20, dy: 0 }, // right + { dx: 0, dy: 20 }, // down + { dx: -20, dy: 0 }, // left + ]; + const off = dirOffsets[this.player.direction] ?? dirOffsets[2]; + const effectX = this.player.worldX + off.dx; + const effectY = this.player.worldY + off.dy; + + // Spawn particles based on item properties + const particleType = heldItem.properties.particle; + if (particleType && particleType !== 'none') { + this.particles.spawn(particleType, effectX, effectY); + } else { + this.particles.spawn('sparks', effectX, effectY); + } + + // Pixel destruction in facing direction + if (heldItem.properties.damage >= 20) { + const worldTileX = Math.floor(effectX / this.tilemap.tileSize); + const worldTileY = Math.floor(effectY / this.tilemap.tileSize); + const radius = Math.min(3, Math.floor(heldItem.properties.damage / 30)); + for (let dy = -radius; dy <= radius; dy++) { + for (let dxx = -radius; dxx <= radius; dxx++) { + if (Math.abs(dxx) + Math.abs(dy) <= radius) { + this.tilemap.setPixel(worldTileX + dxx, worldTileY + dy, MATERIAL_AIR); + } + } + } + } + } + } + // Camera follows player smoothly const lerpSpeed = 0.1; const cx = this.camera.x + (this.player.worldX - this.camera.x) * lerpSpeed; diff --git a/apps/manavoxel/apps/web/src/lib/engine/particles.ts b/apps/manavoxel/apps/web/src/lib/engine/particles.ts new file mode 100644 index 000000000..dd4b4ff8a --- /dev/null +++ b/apps/manavoxel/apps/web/src/lib/engine/particles.ts @@ -0,0 +1,171 @@ +import { Container, Graphics } from 'pixi.js'; + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + life: number; + maxLife: number; + size: number; + color: string; + gravity: number; +} + +const PARTICLE_PRESETS: Record> = { + sparks: { + count: 15, + color: '#FFD54F', + speed: 3, + spread: Math.PI * 2, + life: 20, + size: 3, + gravity: 0.1, + }, + fire_burst: { + count: 25, + color: '#FF4444', + speed: 2, + spread: Math.PI * 2, + life: 30, + size: 4, + gravity: -0.05, + }, + ice_shards: { + count: 12, + color: '#4FC3F7', + speed: 4, + spread: Math.PI * 0.8, + life: 25, + size: 3, + gravity: 0.15, + }, + poison_cloud: { + count: 20, + color: '#66BB6A', + speed: 1, + spread: Math.PI * 2, + life: 40, + size: 5, + gravity: -0.02, + }, + lightning_bolt: { + count: 8, + color: '#FFEB3B', + speed: 6, + spread: Math.PI * 0.3, + life: 10, + size: 2, + gravity: 0, + }, + heal_glow: { + count: 15, + color: '#81C784', + speed: 1.5, + spread: Math.PI * 2, + life: 35, + size: 4, + gravity: -0.08, + }, + shatter: { + count: 30, + color: '#9E9E9E', + speed: 5, + spread: Math.PI * 2, + life: 25, + size: 3, + gravity: 0.2, + }, +}; + +interface ParticleConfig { + x: number; + y: number; + count: number; + color: string; + speed: number; + spread: number; // radians + life: number; // frames + size: number; + gravity: number; +} + +export class ParticleSystem { + private _container: Container; + private _particles: Particle[] = []; + private _graphics: Graphics; + + constructor(worldContainer: Container) { + this._container = new Container(); + this._graphics = new Graphics(); + this._container.addChild(this._graphics); + worldContainer.addChild(this._container); + } + + /** Spawn particles at a world position using a preset name */ + spawn(presetName: string, worldX: number, worldY: number) { + const preset = PARTICLE_PRESETS[presetName]; + if (!preset) return; + + this.spawnCustom({ + x: worldX, + y: worldY, + ...preset, + }); + } + + /** Spawn particles with custom config */ + spawnCustom(config: ParticleConfig) { + const { x, y, count, color, speed, spread, life, size, gravity } = config; + const baseAngle = -Math.PI / 2; // Default: upward + + for (let i = 0; i < count; i++) { + const angle = baseAngle + (Math.random() - 0.5) * spread; + const v = speed * (0.5 + Math.random() * 0.5); + + this._particles.push({ + x, + y, + vx: Math.cos(angle) * v, + vy: Math.sin(angle) * v, + life, + maxLife: life, + size: size * (0.5 + Math.random() * 0.5), + color, + gravity, + }); + } + } + + /** Update all particles (call once per frame) */ + update() { + this._graphics.clear(); + + for (let i = this._particles.length - 1; i >= 0; i--) { + const p = this._particles[i]; + p.x += p.vx; + p.y += p.vy; + p.vy += p.gravity; + p.life--; + + if (p.life <= 0) { + this._particles.splice(i, 1); + continue; + } + + const alpha = p.life / p.maxLife; + const currentSize = p.size * alpha; + + this._graphics.circle(p.x, p.y, currentSize); + this._graphics.fill({ color: p.color, alpha }); + } + } + + get activeCount() { + return this._particles.length; + } + + destroy() { + this._container.destroy({ children: true }); + } +} diff --git a/apps/manavoxel/apps/web/src/routes/+page.svelte b/apps/manavoxel/apps/web/src/routes/+page.svelte index f64a5d53f..bacfd9b9a 100644 --- a/apps/manavoxel/apps/web/src/routes/+page.svelte +++ b/apps/manavoxel/apps/web/src/routes/+page.svelte @@ -6,7 +6,9 @@ import SpriteEditor from '$lib/editor/sprite-editor.svelte'; import type { SpriteData } from '$lib/editor/sprite-editor.svelte'; import InventoryUI from '$lib/components/Inventory.svelte'; - import { Inventory, createItem } from '$lib/engine/inventory'; + import PropertyPanel from '$lib/editor/property-panel.svelte'; + import TriggerEditor from '$lib/editor/trigger-editor.svelte'; + import { Inventory, createItem, type GameItem } from '$lib/engine/inventory'; let canvasContainer: HTMLDivElement; let engine: GameEngine | null = $state(null); @@ -18,6 +20,9 @@ let currentFloor = $state(0); let totalFloors = $state(1); let showSpriteEditor = $state(false); + let showPropertyPanel = $state(false); + let showTriggerEditor = $state(false); + let editingItem = $state(null); let inventory = $state(new Inventory()); let itemCounter = $state(0); @@ -32,6 +37,7 @@ onMount(() => { const e = new GameEngine(canvasContainer); + e.inventory = inventory; engine = e; e.onStateChange = () => { @@ -228,6 +234,11 @@ onDrop={(slot) => { inventory.removeItem(slot); }} + onInspect={(item) => { + editingItem = item; + showPropertyPanel = true; + showTriggerEditor = false; + }} /> {/if} @@ -244,6 +255,64 @@ + + {#if editingItem && (showPropertyPanel || showTriggerEditor)} +
+ +
+ + +
+ + {#if showPropertyPanel} + { + editingItem = updated; + }} + onClose={() => { + showPropertyPanel = false; + editingItem = null; + }} + /> + {/if} + + {#if showTriggerEditor && editingItem} + { + // Store behaviors on the item (extend GameItem type later) + console.log('Behaviors updated:', behaviors); + }} + onClose={() => { + showTriggerEditor = false; + if (!showPropertyPanel) editingItem = null; + }} + /> + {/if} +
+ {/if} + {#if showSpriteEditor}