Add target positions for asteroid orbit and movement
Adds configurable target positions that asteroids can reference: - Orbit mode: asteroid maintains fixed distance to target via constraint - MoveToward mode: asteroid velocity redirected toward target Includes level editor UI for managing targets and asteroid assignments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
71bb2b25da
commit
496bb50095
@ -1,12 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { AsteroidConfig } from '../../levels/config/levelConfig';
|
||||
import type { AsteroidConfig, TargetConfig } from '../../levels/config/levelConfig';
|
||||
import Button from '../shared/Button.svelte';
|
||||
import Section from '../shared/Section.svelte';
|
||||
import Vector3Input from './Vector3Input.svelte';
|
||||
import NumberInput from '../shared/NumberInput.svelte';
|
||||
import FormGroup from '../shared/FormGroup.svelte';
|
||||
import Select from '../shared/Select.svelte';
|
||||
|
||||
export let asteroids: AsteroidConfig[] = [];
|
||||
export let targets: TargetConfig[] = [];
|
||||
|
||||
$: targetOptions = [
|
||||
{ value: '', label: '(None)' },
|
||||
...targets.map(t => ({ value: t.id, label: t.name }))
|
||||
];
|
||||
|
||||
const modeOptions = [
|
||||
{ value: '', label: '(None)' },
|
||||
{ value: 'orbit', label: 'Orbit' },
|
||||
{ value: 'moveToward', label: 'Move Toward' }
|
||||
];
|
||||
|
||||
let editingIndex: number | null = null;
|
||||
let editingAsteroid: AsteroidConfig | null = null;
|
||||
@ -76,6 +89,14 @@
|
||||
</FormGroup>
|
||||
<Vector3Input label="Linear Velocity" bind:value={editingAsteroid.linearVelocity} step={1} />
|
||||
<Vector3Input label="Angular Velocity" bind:value={editingAsteroid.angularVelocity} step={0.1} />
|
||||
<FormGroup label="Target">
|
||||
<Select bind:value={editingAsteroid.targetId} options={targetOptions} />
|
||||
</FormGroup>
|
||||
{#if editingAsteroid.targetId}
|
||||
<FormGroup label="Target Mode">
|
||||
<Select bind:value={editingAsteroid.targetMode} options={modeOptions} />
|
||||
</FormGroup>
|
||||
{/if}
|
||||
<div class="edit-actions">
|
||||
<Button variant="primary" on:click={handleSave}>Save</Button>
|
||||
<Button variant="secondary" on:click={handleCancel}>Cancel</Button>
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
import StarfieldConfigEditor from './StarfieldConfigEditor.svelte';
|
||||
import AsteroidListEditor from './AsteroidListEditor.svelte';
|
||||
import PlanetListEditor from './PlanetListEditor.svelte';
|
||||
import TargetListEditor from './TargetListEditor.svelte';
|
||||
|
||||
export let levelId: string = '';
|
||||
|
||||
@ -33,6 +34,7 @@
|
||||
{ id: 'base', label: '🛬 Base' },
|
||||
{ id: 'sun', label: '☀️ Sun' },
|
||||
{ id: 'starfield', label: '✨ Stars' },
|
||||
{ id: 'targets', label: '🎯 Targets' },
|
||||
{ id: 'asteroids', label: '☄️ Asteroids' },
|
||||
{ id: 'planets', label: '🪐 Planets' }
|
||||
];
|
||||
@ -124,6 +126,12 @@
|
||||
config.starfield = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTargets() {
|
||||
if (config && !config.targets) {
|
||||
config.targets = [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor-container">
|
||||
@ -170,8 +178,12 @@
|
||||
<SunConfigEditor bind:config={config.sun} />
|
||||
{:else if activeTab === 'starfield'}
|
||||
<StarfieldConfigEditor config={config.starfield} onToggle={handleStarfieldToggle} />
|
||||
{:else if activeTab === 'targets'}
|
||||
{ensureTargets()}
|
||||
<TargetListEditor bind:targets={config.targets} />
|
||||
{:else if activeTab === 'asteroids'}
|
||||
<AsteroidListEditor bind:asteroids={config.asteroids} />
|
||||
{ensureTargets()}
|
||||
<AsteroidListEditor bind:asteroids={config.asteroids} targets={config.targets || []} />
|
||||
{:else if activeTab === 'planets'}
|
||||
<PlanetListEditor bind:planets={config.planets} />
|
||||
{/if}
|
||||
|
||||
185
src/components/editor/TargetListEditor.svelte
Normal file
185
src/components/editor/TargetListEditor.svelte
Normal file
@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import type { TargetConfig } from '../../levels/config/levelConfig';
|
||||
import Button from '../shared/Button.svelte';
|
||||
import Section from '../shared/Section.svelte';
|
||||
import Vector3Input from './Vector3Input.svelte';
|
||||
import FormGroup from '../shared/FormGroup.svelte';
|
||||
|
||||
export let targets: TargetConfig[] = [];
|
||||
|
||||
let editingIndex: number | null = null;
|
||||
let editingTarget: TargetConfig | null = null;
|
||||
|
||||
function generateId(): string {
|
||||
return `target-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const newTarget: TargetConfig = {
|
||||
id: generateId(),
|
||||
name: `Target ${targets.length + 1}`,
|
||||
position: [0, 0, 0]
|
||||
};
|
||||
targets = [...targets, newTarget];
|
||||
editingIndex = targets.length - 1;
|
||||
editingTarget = { ...newTarget };
|
||||
}
|
||||
|
||||
function handleEdit(index: number) {
|
||||
editingIndex = index;
|
||||
editingTarget = { ...targets[index] };
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (editingIndex !== null && editingTarget) {
|
||||
targets[editingIndex] = editingTarget;
|
||||
targets = targets;
|
||||
}
|
||||
editingIndex = null;
|
||||
editingTarget = null;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
editingIndex = null;
|
||||
editingTarget = null;
|
||||
}
|
||||
|
||||
function handleDelete(index: number) {
|
||||
if (confirm('Delete this target? Asteroids referencing it will lose their target.')) {
|
||||
targets = targets.filter((_, i) => i !== index);
|
||||
if (editingIndex === index) {
|
||||
editingIndex = null;
|
||||
editingTarget = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatPosition(pos: [number, number, number]): string {
|
||||
return `${pos[0]}, ${pos[1]}, ${pos[2]}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Section title="Target Positions ({targets.length})">
|
||||
<p class="hint">Targets are positions that asteroids can orbit or move toward.</p>
|
||||
|
||||
<div class="target-header">
|
||||
<Button variant="primary" on:click={handleAdd}>+ Add Target</Button>
|
||||
</div>
|
||||
|
||||
{#if editingTarget !== null}
|
||||
<div class="edit-form">
|
||||
<h4>Edit Target: {editingTarget.name}</h4>
|
||||
<FormGroup label="ID (read-only)">
|
||||
<input type="text" class="settings-input" value={editingTarget.id} disabled />
|
||||
</FormGroup>
|
||||
<FormGroup label="Name">
|
||||
<input type="text" class="settings-input" bind:value={editingTarget.name} />
|
||||
</FormGroup>
|
||||
<Vector3Input label="Position" bind:value={editingTarget.position} step={50} />
|
||||
<div class="edit-actions">
|
||||
<Button variant="primary" on:click={handleSave}>Save</Button>
|
||||
<Button variant="secondary" on:click={handleCancel}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if targets.length === 0}
|
||||
<p class="empty-message">No targets configured. Add targets for asteroids to orbit or move toward.</p>
|
||||
{:else}
|
||||
<div class="target-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>ID</th>
|
||||
<th>Position</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each targets as target, index}
|
||||
<tr class:editing={editingIndex === index}>
|
||||
<td>{target.name}</td>
|
||||
<td class="id-cell">{target.id}</td>
|
||||
<td>{formatPosition(target.position)}</td>
|
||||
<td class="actions">
|
||||
<Button variant="secondary" on:click={() => handleEdit(index)}>Edit</Button>
|
||||
<Button variant="danger" on:click={() => handleDelete(index)}>x</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
<style>
|
||||
.hint {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary, #888);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.target-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
background: var(--color-bg-secondary, #1a1a2e);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.edit-form h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--color-text-primary, #fff);
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.target-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--color-bg-secondary, #1a1a2e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tr.editing {
|
||||
background: var(--color-bg-hover, #252540);
|
||||
}
|
||||
|
||||
.id-cell {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #888);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
color: var(--color-text-secondary, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@ -41,6 +41,8 @@ interface RockConfig {
|
||||
angularVelocity: Vector3;
|
||||
scoreObservable: Observable<ScoreEvent>;
|
||||
useOrbitConstraint: boolean;
|
||||
targetPosition?: Vector3;
|
||||
targetMode?: 'orbit' | 'moveToward';
|
||||
}
|
||||
|
||||
export class RockFactory {
|
||||
@ -166,7 +168,9 @@ export class RockFactory {
|
||||
angularVelocity: Vector3,
|
||||
scoreObservable: Observable<ScoreEvent>,
|
||||
useOrbitConstraint: boolean = true,
|
||||
hidden: boolean = false
|
||||
hidden: boolean = false,
|
||||
targetPosition?: Vector3,
|
||||
targetMode?: 'orbit' | 'moveToward'
|
||||
): Rock {
|
||||
if (!this._asteroidMesh) {
|
||||
throw new Error('[RockFactory] Asteroid mesh not loaded. Call initMesh() first.');
|
||||
@ -188,11 +192,13 @@ export class RockFactory {
|
||||
linearVelocity,
|
||||
angularVelocity,
|
||||
scoreObservable,
|
||||
useOrbitConstraint
|
||||
useOrbitConstraint,
|
||||
targetPosition,
|
||||
targetMode
|
||||
};
|
||||
this._createdRocks.set(rock.id, { mesh: rock, config });
|
||||
|
||||
log.debug(`[RockFactory] Created rock mesh ${rock.id} (hidden: ${hidden})`);
|
||||
log.debug(`[RockFactory] Created rock mesh ${rock.id} (hidden: ${hidden}, target: ${targetMode || 'none'})`);
|
||||
return new Rock(rock);
|
||||
}
|
||||
|
||||
@ -215,8 +221,11 @@ export class RockFactory {
|
||||
body.setMotionType(PhysicsMotionType.DYNAMIC);
|
||||
body.setCollisionCallbackEnabled(true);
|
||||
|
||||
// Apply orbit constraint if enabled
|
||||
if (config.useOrbitConstraint && this._orbitCenter) {
|
||||
// Handle target-based physics
|
||||
if (config.targetPosition && config.targetMode) {
|
||||
this.applyTargetPhysics(body, config);
|
||||
} else if (config.useOrbitConstraint && this._orbitCenter) {
|
||||
// Legacy: orbit around origin if no specific target
|
||||
const constraint = new DistanceConstraint(
|
||||
Vector3.Distance(config.position, this._orbitCenter.body.transformNode.position),
|
||||
DefaultScene.MainScene
|
||||
@ -230,7 +239,10 @@ export class RockFactory {
|
||||
physicsPlugin.setActivationControl(body, PhysicsActivationControl.ALWAYS_ACTIVE);
|
||||
}
|
||||
|
||||
body.setLinearVelocity(config.linearVelocity);
|
||||
// Apply velocity (may be modified by applyTargetPhysics for moveToward mode)
|
||||
if (!(config.targetPosition && config.targetMode === 'moveToward')) {
|
||||
body.setLinearVelocity(config.linearVelocity);
|
||||
}
|
||||
body.setAngularVelocity(config.angularVelocity);
|
||||
|
||||
// Setup collision handler
|
||||
@ -239,6 +251,45 @@ export class RockFactory {
|
||||
log.debug(`[RockFactory] Physics initialized for ${rock.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply target-based physics (orbit or moveToward)
|
||||
*/
|
||||
private static applyTargetPhysics(body: PhysicsBody, config: RockConfig): void {
|
||||
if (!config.targetPosition) return;
|
||||
|
||||
if (config.targetMode === 'orbit') {
|
||||
// Create distance constraint to target position
|
||||
// We need a static body at the target position for the constraint
|
||||
const targetNode = new TransformNode(`target-${body.transformNode.id}`, DefaultScene.MainScene);
|
||||
targetNode.position = config.targetPosition;
|
||||
const targetBody = new PhysicsAggregate(
|
||||
targetNode, PhysicsShapeType.SPHERE,
|
||||
{ radius: 0.1, mass: 0 },
|
||||
DefaultScene.MainScene
|
||||
);
|
||||
targetBody.body.setMotionType(PhysicsMotionType.STATIC);
|
||||
|
||||
const distance = Vector3.Distance(config.position, config.targetPosition);
|
||||
const constraint = new DistanceConstraint(distance, DefaultScene.MainScene);
|
||||
body.addConstraint(targetBody.body, constraint);
|
||||
|
||||
// Apply original velocity for orbiting
|
||||
body.setLinearVelocity(config.linearVelocity);
|
||||
} else if (config.targetMode === 'moveToward') {
|
||||
// Calculate speed as sum of absolute velocity components
|
||||
const speed = Math.abs(config.linearVelocity.x) +
|
||||
Math.abs(config.linearVelocity.y) +
|
||||
Math.abs(config.linearVelocity.z);
|
||||
|
||||
// Direction toward target
|
||||
const direction = config.targetPosition.subtract(config.position).normalize();
|
||||
|
||||
// Final velocity = direction * speed
|
||||
const velocity = direction.scale(speed);
|
||||
body.setLinearVelocity(velocity);
|
||||
}
|
||||
}
|
||||
|
||||
private static setupCollisionHandler(body: PhysicsBody, scoreObservable: Observable<ScoreEvent>): void {
|
||||
body.getCollisionObservable().add((eventData) => {
|
||||
if (eventData.type !== 'COLLISION_STARTED') return;
|
||||
|
||||
@ -111,16 +111,27 @@ interface PlanetConfig {
|
||||
rotation?: Vector3Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Target position for asteroids to orbit or move toward
|
||||
*/
|
||||
export interface TargetConfig {
|
||||
id: string;
|
||||
name: string; // Display name for editor
|
||||
position: Vector3Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual asteroid configuration
|
||||
*/
|
||||
interface AsteroidConfig {
|
||||
export interface AsteroidConfig {
|
||||
id: string;
|
||||
position: Vector3Array;
|
||||
scale: number; // Uniform scale applied to all axes
|
||||
linearVelocity: Vector3Array;
|
||||
angularVelocity?: Vector3Array;
|
||||
mass?: number;
|
||||
targetId?: string; // Reference to target from targets array
|
||||
targetMode?: 'orbit' | 'moveToward'; // How asteroid interacts with target
|
||||
}
|
||||
|
||||
/**
|
||||
@ -155,6 +166,7 @@ export interface LevelConfig {
|
||||
startBase?: StartBaseConfig;
|
||||
sun: SunConfig;
|
||||
starfield?: StarfieldConfig;
|
||||
targets?: TargetConfig[];
|
||||
planets: PlanetConfig[];
|
||||
asteroids: AsteroidConfig[];
|
||||
|
||||
|
||||
@ -239,6 +239,15 @@ export class LevelDeserializer {
|
||||
const asteroidConfig = this.config.asteroids[i];
|
||||
const useOrbitConstraints = this.config.useOrbitConstraints !== false;
|
||||
|
||||
// Resolve target position if specified
|
||||
let targetPosition: Vector3 | undefined;
|
||||
if (asteroidConfig.targetId && this.config.targets) {
|
||||
const target = this.config.targets.find(t => t.id === asteroidConfig.targetId);
|
||||
if (target) {
|
||||
targetPosition = this.arrayToVector3(target.position);
|
||||
}
|
||||
}
|
||||
|
||||
// Create mesh only (no physics)
|
||||
RockFactory.createRockMesh(
|
||||
i,
|
||||
@ -248,7 +257,9 @@ export class LevelDeserializer {
|
||||
this.arrayToVector3(asteroidConfig.angularVelocity),
|
||||
scoreObservable,
|
||||
useOrbitConstraints,
|
||||
hidden
|
||||
hidden,
|
||||
targetPosition,
|
||||
asteroidConfig.targetMode
|
||||
);
|
||||
|
||||
const mesh = this.scene.getMeshByName(asteroidConfig.id);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user