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:
Michael Mainguy 2025-12-02 16:47:58 -06:00
parent 71bb2b25da
commit 496bb50095
6 changed files with 302 additions and 10 deletions

View File

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

View File

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

View 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>

View File

@ -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);
}
// 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;

View File

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

View File

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