mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
9675520dbd
commit
d71eade816
6 changed files with 829 additions and 2 deletions
|
|
@ -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);
|
||||
|
|
|
|||
221
apps/manavoxel/apps/web/src/lib/editor/property-panel.svelte
Normal file
221
apps/manavoxel/apps/web/src/lib/editor/property-panel.svelte
Normal 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>
|
||||
306
apps/manavoxel/apps/web/src/lib/editor/trigger-editor.svelte
Normal file
306
apps/manavoxel/apps/web/src/lib/editor/trigger-editor.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
171
apps/manavoxel/apps/web/src/lib/engine/particles.ts
Normal file
171
apps/manavoxel/apps/web/src/lib/engine/particles.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue