feat(manavoxel): add item programming system (Phase 2)

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 09:24:20 +02:00
parent 9675520dbd
commit d71eade816
6 changed files with 829 additions and 2 deletions

View file

@ -1,7 +1,11 @@
<script lang="ts">
import { type Inventory, type GameItem, MAX_INVENTORY_SLOTS } from '$lib/engine/inventory';
let { inventory, onDrop = undefined as ((slot: number) => void) | undefined } = $props();
let {
inventory,
onDrop = undefined as ((slot: number) => void) | undefined,
onInspect = undefined as ((item: GameItem) => void) | undefined,
} = $props();
function drawSprite(canvas: HTMLCanvasElement, item: GameItem) {
const ctx = canvas.getContext('2d')!;
@ -53,6 +57,9 @@
? `${rarityBorder[item.rarity] ?? 'border-gray-600'} bg-gray-900 hover:bg-gray-800`
: 'border-gray-700/50 bg-gray-900/30'}"
onclick={() => inventory.selectSlot(i)}
ondblclick={() => {
if (item) onInspect?.(item);
}}
oncontextmenu={(e) => {
e.preventDefault();
if (item) onDrop?.(i);

View file

@ -0,0 +1,221 @@
<script lang="ts">
import type { GameItem } from '$lib/engine/inventory';
import type { ElementType, Rarity } from '@manavoxel/shared';
let {
item,
onUpdate = undefined as ((item: GameItem) => void) | undefined,
onClose = undefined as (() => void) | undefined,
} = $props<{ item: GameItem; onUpdate?: (item: GameItem) => void; onClose?: () => void }>();
// Local reactive copies of properties
let damage = $state(item.properties.damage);
let range = $state(item.properties.range);
let speed = $state(item.properties.speed);
let durabilityMax = $state(item.properties.durabilityMax);
let element = $state<ElementType>(item.properties.element);
let rarity = $state<Rarity>(item.properties.rarity);
let sound = $state(item.properties.sound);
let particle = $state(item.properties.particle);
let itemName = $state(item.name);
const elements: { value: ElementType; label: string; color: string }[] = [
{ value: 'neutral', label: 'Neutral', color: '#9CA3AF' },
{ value: 'fire', label: 'Fire', color: '#EF4444' },
{ value: 'ice', label: 'Ice', color: '#3B82F6' },
{ value: 'poison', label: 'Poison', color: '#22C55E' },
{ value: 'lightning', label: 'Lightning', color: '#EAB308' },
];
const rarities: { value: Rarity; label: string; color: string }[] = [
{ value: 'common', label: 'Common', color: '#9CA3AF' },
{ value: 'uncommon', label: 'Uncommon', color: '#22C55E' },
{ value: 'rare', label: 'Rare', color: '#3B82F6' },
{ value: 'epic', label: 'Epic', color: '#A855F7' },
{ value: 'legendary', label: 'Legendary', color: '#EAB308' },
];
const sounds = [
'hit_default',
'hit_sword',
'hit_blunt',
'hit_magic',
'whoosh',
'explosion',
'heal',
'pickup',
'drop',
'break',
];
const particles = [
'none',
'sparks',
'fire_burst',
'ice_shards',
'poison_cloud',
'lightning_bolt',
'heal_glow',
'shatter',
];
function save() {
item.name = itemName;
item.rarity = rarity;
item.properties = {
...item.properties,
damage,
range,
speed,
durabilityMax,
durabilityCurrent: durabilityMax,
element,
rarity,
sound,
particle,
};
onUpdate?.(item);
}
// Auto-save on any change
$effect(() => {
// Touch all reactive values to track them
void [damage, range, speed, durabilityMax, element, rarity, sound, particle, itemName];
save();
});
</script>
<div class="flex w-64 flex-col gap-3 rounded-xl bg-gray-900 p-4 shadow-2xl">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-white">Item Properties</h3>
{#if onClose}
<button class="text-gray-500 hover:text-white" onclick={onClose}>X</button>
{/if}
</div>
<!-- Name -->
<div>
<label class="mb-1 block text-xs text-gray-500">Name</label>
<input
type="text"
bind:value={itemName}
class="w-full rounded bg-gray-800 px-2 py-1 text-sm text-white outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
<!-- Damage -->
<div>
<label class="mb-1 flex justify-between text-xs text-gray-500">
<span>Damage</span>
<span class="text-white">{damage}</span>
</label>
<input type="range" min="0" max="100" bind:value={damage} class="w-full accent-red-500" />
</div>
<!-- Range -->
<div>
<label class="mb-1 flex justify-between text-xs text-gray-500">
<span>Range</span>
<span class="text-white">{range}</span>
</label>
<input type="range" min="1" max="10" bind:value={range} class="w-full accent-blue-500" />
</div>
<!-- Speed -->
<div>
<label class="mb-1 flex justify-between text-xs text-gray-500">
<span>Speed</span>
<span class="text-white">{speed}</span>
</label>
<input
type="range"
min="1"
max="10"
step="0.5"
bind:value={speed}
class="w-full accent-yellow-500"
/>
</div>
<!-- Durability -->
<div>
<label class="mb-1 flex justify-between text-xs text-gray-500">
<span>Durability</span>
<span class="text-white">{durabilityMax}</span>
</label>
<input
type="range"
min="1"
max="200"
bind:value={durabilityMax}
class="w-full accent-green-500"
/>
</div>
<!-- Element -->
<div>
<label class="mb-1 block text-xs text-gray-500">Element</label>
<div class="flex gap-1">
{#each elements as el}
<button
class="rounded px-2 py-1 text-xs transition {element === el.value
? 'ring-1 ring-white'
: 'opacity-60 hover:opacity-100'}"
style="background-color: {el.color}20; color: {el.color}"
onclick={() => (element = el.value)}
>
{el.label}
</button>
{/each}
</div>
</div>
<!-- Rarity -->
<div>
<label class="mb-1 block text-xs text-gray-500">Rarity</label>
<div class="flex gap-1">
{#each rarities as r}
<button
class="rounded px-1.5 py-0.5 text-[10px] transition {rarity === r.value
? 'ring-1 ring-white'
: 'opacity-50 hover:opacity-100'}"
style="background-color: {r.color}20; color: {r.color}"
onclick={() => (rarity = r.value)}
>
{r.label}
</button>
{/each}
</div>
</div>
<!-- Sound -->
<div>
<label class="mb-1 block text-xs text-gray-500">Sound</label>
<select
bind:value={sound}
class="w-full rounded bg-gray-800 px-2 py-1 text-xs text-white outline-none"
>
{#each sounds as s}
<option value={s}>{s.replace('_', ' ')}</option>
{/each}
</select>
</div>
<!-- Particle -->
<div>
<label class="mb-1 block text-xs text-gray-500">Particle Effect</label>
<select
bind:value={particle}
class="w-full rounded bg-gray-800 px-2 py-1 text-xs text-white outline-none"
>
{#each particles as p}
<option value={p}>{p === 'none' ? 'None' : p.replace('_', ' ')}</option>
{/each}
</select>
</div>
<!-- Summary -->
<div class="rounded bg-gray-800/50 p-2 text-[10px] text-gray-400">
{itemName} | {element} | {damage} dmg | {rarity}
</div>
</div>

View file

@ -0,0 +1,306 @@
<script lang="ts">
import type { TriggerAction } from '@manavoxel/shared';
let {
behaviors = [] as TriggerAction[],
onUpdate = undefined as ((behaviors: TriggerAction[]) => void) | undefined,
onClose = undefined as (() => void) | undefined,
} = $props();
let rules = $state<TriggerAction[]>(structuredClone(behaviors));
const triggerTypes = [
{ value: 'onTouch', label: 'Player touches' },
{ value: 'onUse', label: 'Item used' },
{ value: 'onPickup', label: 'Item picked up' },
{ value: 'onDrop', label: 'Item dropped' },
{ value: 'onTimer', label: 'Every X seconds' },
{ value: 'onHpBelow', label: 'HP below X' },
{ value: 'onNearItem', label: 'Item nearby' },
{ value: 'onAreaEnter', label: 'Entering area' },
{ value: 'onCustomEvent', label: 'Custom event' },
{ value: 'onDayNight', label: 'Day/Night change' },
];
const actionTypes = [
{ value: 'damage', label: 'Deal damage', params: ['amount'] },
{ value: 'heal', label: 'Heal', params: ['amount'] },
{ value: 'particle', label: 'Spawn particles', params: ['type'] },
{ value: 'sound', label: 'Play sound', params: ['name'] },
{ value: 'setPixel', label: 'Place pixel', params: ['material', 'radius'] },
{ value: 'deletePixel', label: 'Destroy pixels', params: ['radius'] },
{ value: 'teleport', label: 'Teleport player', params: ['x', 'y'] },
{ value: 'message', label: 'Show message', params: ['text'] },
{ value: 'setVariable', label: 'Set variable', params: ['name', 'value'] },
{ value: 'getVariable', label: 'Check variable', params: ['name'] },
{ value: 'sendEvent', label: 'Send event', params: ['eventName'] },
{ value: 'giveItem', label: 'Give item', params: ['itemId'] },
{ value: 'light', label: 'Toggle light', params: ['color', 'radius'] },
{ value: 'cameraShake', label: 'Camera shake', params: ['intensity'] },
{ value: 'wait', label: 'Wait', params: ['seconds'] },
];
const particleTypes = [
'sparks',
'fire_burst',
'ice_shards',
'poison_cloud',
'lightning_bolt',
'heal_glow',
'shatter',
];
const soundNames = [
'hit_default',
'hit_sword',
'explosion',
'heal',
'whoosh',
'pickup',
'break',
'magic',
];
function addRule() {
rules = [
...rules,
{
trigger: { type: 'onUse', params: {} },
actions: [{ type: 'damage', params: { amount: 10 } }],
},
];
save();
}
function removeRule(index: number) {
rules = rules.filter((_, i) => i !== index);
save();
}
function addAction(ruleIndex: number) {
rules[ruleIndex].actions = [
...rules[ruleIndex].actions,
{ type: 'particle', params: { type: 'sparks' } },
];
save();
}
function removeAction(ruleIndex: number, actionIndex: number) {
rules[ruleIndex].actions = rules[ruleIndex].actions.filter((_, i) => i !== actionIndex);
save();
}
function save() {
onUpdate?.(structuredClone(rules));
}
function getParamValue(params: Record<string, unknown>, key: string): string {
return String(params[key] ?? '');
}
function setParam(params: Record<string, unknown>, key: string, value: string) {
const num = Number(value);
params[key] = isNaN(num) ? value : num;
save();
}
</script>
<div class="flex w-80 flex-col gap-3 rounded-xl bg-gray-900 p-4 shadow-2xl">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-white">Behaviors</h3>
<div class="flex gap-2">
<button
class="rounded bg-emerald-600 px-2 py-0.5 text-xs text-white hover:bg-emerald-500"
onclick={addRule}
>
+ Rule
</button>
{#if onClose}
<button class="text-gray-500 hover:text-white" onclick={onClose}>X</button>
{/if}
</div>
</div>
{#if rules.length === 0}
<div class="py-4 text-center text-xs text-gray-500">
No behaviors yet. Click "+ Rule" to add one.
</div>
{/if}
<div class="flex max-h-[400px] flex-col gap-3 overflow-y-auto">
{#each rules as rule, ri}
<div class="rounded-lg border border-gray-700 bg-gray-800/50 p-2">
<!-- Trigger -->
<div class="mb-2 flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-yellow-500">WHEN</span>
<select
bind:value={rule.trigger.type}
onchange={save}
class="flex-1 rounded bg-gray-700 px-1.5 py-0.5 text-xs text-white outline-none"
>
{#each triggerTypes as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
<button class="text-xs text-red-400 hover:text-red-300" onclick={() => removeRule(ri)}>
Del
</button>
</div>
<!-- Trigger params -->
{#if rule.trigger.type === 'onTimer'}
<div class="mb-2 ml-4 flex items-center gap-1">
<span class="text-[10px] text-gray-500">every</span>
<input
type="number"
value={getParamValue(rule.trigger.params, 'seconds')}
oninput={(e) => 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"
/>
<span class="text-[10px] text-gray-500">sec</span>
</div>
{/if}
{#if rule.trigger.type === 'onHpBelow'}
<div class="mb-2 ml-4 flex items-center gap-1">
<span class="text-[10px] text-gray-500">HP below</span>
<input
type="number"
value={getParamValue(rule.trigger.params, 'threshold')}
oninput={(e) => 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"
/>
</div>
{/if}
<!-- Actions -->
{#each rule.actions as action, ai}
<div class="mb-1 ml-2 flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-emerald-500">
{ai === 0 ? 'THEN' : 'AND'}
</span>
<select
bind:value={action.type}
onchange={save}
class="flex-1 rounded bg-gray-700 px-1.5 py-0.5 text-xs text-white outline-none"
>
{#each actionTypes as a}
<option value={a.value}>{a.label}</option>
{/each}
</select>
<button
class="text-[10px] text-red-400 hover:text-red-300"
onclick={() => removeAction(ri, ai)}
>
x
</button>
</div>
<!-- Action params -->
<div class="mb-1 ml-8 flex flex-wrap gap-1">
{#if action.type === 'damage' || action.type === 'heal'}
<input
type="number"
value={getParamValue(action.params, 'amount')}
oninput={(e) => 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'}
<select
value={getParamValue(action.params, 'type')}
onchange={(e) => setParam(action.params, 'type', e.currentTarget.value)}
class="rounded bg-gray-700 px-1 py-0.5 text-xs text-white outline-none"
>
{#each particleTypes as p}
<option value={p}>{p.replace('_', ' ')}</option>
{/each}
</select>
{/if}
{#if action.type === 'sound'}
<select
value={getParamValue(action.params, 'name')}
onchange={(e) => setParam(action.params, 'name', e.currentTarget.value)}
class="rounded bg-gray-700 px-1 py-0.5 text-xs text-white outline-none"
>
{#each soundNames as s}
<option value={s}>{s.replace('_', ' ')}</option>
{/each}
</select>
{/if}
{#if action.type === 'deletePixel' || action.type === 'setPixel'}
<input
type="number"
value={getParamValue(action.params, 'radius')}
oninput={(e) => 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'}
<input
type="text"
value={getParamValue(action.params, 'text')}
oninput={(e) => 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'}
<input
type="number"
value={getParamValue(action.params, 'seconds')}
oninput={(e) => 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'}
<input
type="number"
value={getParamValue(action.params, 'intensity')}
oninput={(e) => 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}
</div>
{/each}
<button
class="ml-2 mt-1 text-[10px] text-gray-500 hover:text-emerald-400"
onclick={() => addAction(ri)}
>
+ Add action
</button>
</div>
{/each}
</div>
<!-- Preview as readable text -->
{#if rules.length > 0}
<div class="rounded bg-gray-800/50 p-2">
<div class="mb-1 text-[10px] text-gray-500">Preview:</div>
{#each rules as rule}
<div class="text-[10px] text-gray-400">
<span class="text-yellow-500">WHEN</span>
{triggerTypes.find((t) => t.value === rule.trigger.type)?.label ?? rule.trigger.type}
{#each rule.actions as action, i}
<span class="text-emerald-500">{i === 0 ? ' THEN ' : ' AND '}</span>
{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}
</div>
{/each}
</div>
{/if}
</div>

View file

@ -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;

View file

@ -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<string, Omit<ParticleConfig, 'x' | 'y'>> = {
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 });
}
}

View file

@ -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<GameItem | null>(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;
}}
/>
</div>
{/if}
@ -244,6 +255,64 @@
</div>
</div>
<!-- Item Editor Panels (right sidebar) -->
{#if editingItem && (showPropertyPanel || showTriggerEditor)}
<div class="pointer-events-auto absolute right-4 top-16 z-40 flex flex-col gap-2">
<!-- Tab buttons -->
<div class="flex gap-1">
<button
class="rounded-lg px-3 py-1 text-xs transition {showPropertyPanel && !showTriggerEditor
? 'bg-emerald-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'}"
onclick={() => {
showPropertyPanel = true;
showTriggerEditor = false;
}}
>
Properties
</button>
<button
class="rounded-lg px-3 py-1 text-xs transition {showTriggerEditor
? 'bg-emerald-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'}"
onclick={() => {
showTriggerEditor = true;
showPropertyPanel = false;
}}
>
Behaviors
</button>
</div>
{#if showPropertyPanel}
<PropertyPanel
item={editingItem}
onUpdate={(updated) => {
editingItem = updated;
}}
onClose={() => {
showPropertyPanel = false;
editingItem = null;
}}
/>
{/if}
{#if showTriggerEditor && editingItem}
<TriggerEditor
behaviors={[]}
onUpdate={(behaviors) => {
// Store behaviors on the item (extend GameItem type later)
console.log('Behaviors updated:', behaviors);
}}
onClose={() => {
showTriggerEditor = false;
if (!showPropertyPanel) editingItem = null;
}}
/>
{/if}
</div>
{/if}
<!-- Sprite Editor Modal -->
{#if showSpriteEditor}
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/70">