CUB-60: implement minion state machine and animation system
- Add MinionState type and MinionData model with four states (idle, walking, working, returning) - Add MinionStateService with full state machine: spawn → walking → working → returning → idle - Create MinionComponent with 16-bit pixel art CSS animations: - idle: banana-eating loop animation with gentle float - walking: 2s translate-right walk cycle with arm/leg swing - working: typing keyframe animation with laptop and progress bar - returning: 2s translate-left walk-back animation - Add MinionState transition map with event-driven dispatch - Create BreakroomPage demo with spawn/complete/error/reset controls - Add /breakroom route for testing state transitions - Touch-optimized: 48px min targets, responsive scaling - Full reduced-motion accessibility support - TypeScript strict: no any, all inputs typed
This commit is contained in:
182
frontend/src/app/components/minion/minion.component.ts
Normal file
182
frontend/src/app/components/minion/minion.component.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// ============================================================================
|
||||
// Minion Component — 16-bit Breakroom Minion with State Animations
|
||||
// Per CUB-60: Minion State & Animation System
|
||||
// ============================================================================
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MinionState, MinionSide } from '../../models/minion.model';
|
||||
import { MinionStateService } from '../../services/minion-state.service';
|
||||
|
||||
/**
|
||||
* MinionComponent renders a single 16-bit minion character with four visual states:
|
||||
* - idle: In breakroom — eating bananas, watching TV
|
||||
* - walking: Moving from breakroom to desk (2s animation, then auto → working)
|
||||
* - working: At desk — typing on laptop, progress bar overhead
|
||||
* - returning: Walking back to breakroom (2s animation, then auto → idle)
|
||||
*
|
||||
* The component reads its state from MinionStateService and emits events
|
||||
* when animations complete so the service can advance the state machine.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-minion',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatTooltipModule,
|
||||
],
|
||||
templateUrl: './minion.component.html',
|
||||
styleUrl: './minion.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MinionComponent implements OnInit, OnDestroy {
|
||||
/** Agent name — used to look up state from MinionStateService */
|
||||
readonly agentName = input.required<string>();
|
||||
|
||||
/** Which side of the office: dev or business */
|
||||
readonly side = input<MinionSide>('dev');
|
||||
|
||||
/** Desk index on the assigned side (0-based) */
|
||||
readonly deskIndex = input<number>(0);
|
||||
|
||||
/** Override state (for standalone demo use, otherwise read from service) */
|
||||
readonly state = input<MinionState>('idle');
|
||||
|
||||
/** Override progress (for standalone demo use) */
|
||||
readonly progress = input<number>(0);
|
||||
|
||||
/** Override display name (for standalone demo use) */
|
||||
readonly displayName = input<string>('');
|
||||
|
||||
/** Override task description (for standalone demo use) */
|
||||
readonly currentTask = input<string>('');
|
||||
|
||||
/** Emits when the walking animation completes */
|
||||
readonly walkingComplete = output<string>();
|
||||
|
||||
/** Emits when the returning animation completes */
|
||||
readonly returningComplete = output<string>();
|
||||
|
||||
private readonly minionStateService = inject(MinionStateService);
|
||||
|
||||
/** Track whether to use service data or input data */
|
||||
private readonly useServiceData = signal(false);
|
||||
|
||||
/** Animation state — tracks which animation is active */
|
||||
readonly animationState = signal<MinionState | 'none'>('none');
|
||||
|
||||
/** Computed: current state (from service if available, otherwise input) */
|
||||
readonly currentState = computed<MinionState>(() => {
|
||||
if (this.useServiceData()) {
|
||||
const minion = this.minionStateService.getMinion(this.agentName());
|
||||
return minion?.state ?? this.state();
|
||||
}
|
||||
return this.state();
|
||||
});
|
||||
|
||||
/** Computed: current progress */
|
||||
readonly currentProgress = computed(() => {
|
||||
if (this.useServiceData()) {
|
||||
const minion = this.minionStateService.getMinion(this.agentName());
|
||||
return minion?.progress ?? this.progress();
|
||||
}
|
||||
return this.progress();
|
||||
});
|
||||
|
||||
/** Computed: display name */
|
||||
readonly displayLabel = computed(() => {
|
||||
if (this.useServiceData()) {
|
||||
const minion = this.minionStateService.getMinion(this.agentName());
|
||||
return minion?.displayName ?? this.displayName() ?? this.agentName();
|
||||
}
|
||||
return this.displayName() || this.agentName();
|
||||
});
|
||||
|
||||
/** Computed: current task */
|
||||
readonly currentTaskLabel = computed(() => {
|
||||
if (this.useServiceData()) {
|
||||
const minion = this.minionStateService.getMinion(this.agentName());
|
||||
return minion?.currentTask ?? this.currentTask() ?? '';
|
||||
}
|
||||
return this.currentTask();
|
||||
});
|
||||
|
||||
/** Computed: CSS class based on current state */
|
||||
readonly stateClass = computed(() => `minion--${this.currentState()}`);
|
||||
|
||||
/** Computed: whether to show the progress bar */
|
||||
readonly showProgress = computed(() => this.currentState() === 'working' && this.currentProgress() > 0);
|
||||
|
||||
/** Computed: side class */
|
||||
readonly sideClass = computed(() => `minion--${this.side()}`);
|
||||
|
||||
/** Computed: position style for desk assignment */
|
||||
readonly deskPosition = computed(() => this.deskIndex());
|
||||
|
||||
/** Computed: human-readable state label */
|
||||
readonly stateLabel = computed(() => {
|
||||
const labels: Record<MinionState, string> = {
|
||||
idle: 'On Break',
|
||||
walking: 'Heading to Desk',
|
||||
working: 'Working',
|
||||
returning: 'Returning to Breakroom',
|
||||
};
|
||||
return labels[this.currentState()];
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
// If the service has data for this agent, use it
|
||||
const minion = this.minionStateService.getMinion(this.agentName());
|
||||
if (minion) {
|
||||
this.useServiceData.set(true);
|
||||
}
|
||||
|
||||
// Sync animation state with current state
|
||||
this.animationState.set(this.currentState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle animation end events from CSS animations.
|
||||
* Only fires for walking/returning which have finite durations.
|
||||
*/
|
||||
onAnimationEnd(event: AnimationEvent): void {
|
||||
const state = this.currentState();
|
||||
|
||||
if (state === 'walking' && event.animationName === 'minionWalkRight') {
|
||||
this.walkingComplete.emit(this.agentName());
|
||||
// Auto-transition walking → working via service
|
||||
if (this.useServiceData()) {
|
||||
this.minionStateService.onWalkingComplete(this.agentName());
|
||||
}
|
||||
this.animationState.set('working');
|
||||
}
|
||||
|
||||
if (state === 'returning' && event.animationName === 'minionWalkLeft') {
|
||||
this.returningComplete.emit(this.agentName());
|
||||
// Auto-transition returning → idle via service
|
||||
if (this.useServiceData()) {
|
||||
this.minionStateService.onReturningComplete(this.agentName());
|
||||
}
|
||||
this.animationState.set('idle');
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Cleanup handled by service if needed
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user