All files / src/missions field-layout-manager.ts

70.21% Statements 66/94
27.77% Branches 5/18
50% Functions 3/6
70.21% Lines 66/94

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153    1x                                       1x   1x       1x 2x 6x 6x 6x 6x 6x   6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 2x   2x 2x 2x 2x 2x 2x   2x 2x 2x       2x 2x         1x 1x 1x 1x 1x 1x     1x         1x             1x                             1x                       1x 1x 1x 1x 1x 1x   1x 3x     3x         3x   3x     3x 3x 3x       3x 3x 3x 3x 3x 3x   1x 1x 1x 1x  
import { MissionElement } from './types';
import { MissionManager } from './mission-manager';
import { CustomImportedMissionElement } from './custom-imported-element';
 
export interface MissionElementLayoutEntry {
  elementId: string;
  parentMissionId?: string;
  name: string;
  position: { x: number; y: number; z: number };
  yawDegrees: number;
  isDualLocked: boolean;
  dualLockPosition?: { x: number; z: number } | null;
  anchoredClusterId?: string | null;
}
 
export interface FieldLayoutData {
  version: number;
  name: string;
  timestamp: number;
  elements: MissionElementLayoutEntry[];
}
 
const STORAGE_KEY = 'fll_field_custom_layout_v1';
 
export class FieldLayoutManager {
  /**
   * Saves the current positions and Dual Lock states of all elements to LocalStorage
   */
  public static saveCurrentLayout(elements: MissionElement[]): FieldLayoutData {
    const layoutEntries: MissionElementLayoutEntry[] = elements.map((elem) => {
      const pos = elem.getPosition();
      const yaw = elem.getYawDegrees ? elem.getYawDegrees() : 0;
      const isLocked = elem.isDualLocked ?? (elem instanceof CustomImportedMissionElement ? elem.getIsBaseFixed() : true);
      const lockPos = elem.getDualLockPosition ? elem.getDualLockPosition() : (isLocked ? { x: pos.x, z: pos.z } : null);
      const clusterId = (elem as any).getAnchoredClusterId ? (elem as any).getAnchoredClusterId() : null;
 
      return {
        elementId: elem.id,
        parentMissionId: elem.parentMissionId,
        name: elem.name,
        position: { x: pos.x, y: pos.y, z: pos.z },
        yawDegrees: yaw,
        isDualLocked: isLocked,
        dualLockPosition: lockPos,
        anchoredClusterId: clusterId,
      };
    });
 
    const layoutData: FieldLayoutData = {
      version: 1,
      name: `Custom FLL Layout (${new Date().toLocaleTimeString()})`,
      timestamp: Date.now(),
      elements: layoutEntries,
    };
 
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(layoutData));
    } catch (e) {
      console.warn('[FieldLayoutManager] Failed to save to localStorage:', e);
    }
 
    return layoutData;
  }
 
  /**
   * Loads saved layout from LocalStorage
   */
  public static loadSavedLayout(): FieldLayoutData | null {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return null;
      return JSON.parse(raw) as FieldLayoutData;
    } catch {
      return null;
    }
  }
 
  /**
   * Returns true if a layout is saved in LocalStorage
   */
  public static hasSavedLayout(): boolean {
    return localStorage.getItem(STORAGE_KEY) !== null;
  }
 
  /**
   * Exports a layout as a downloadable JSON file
   */
  public static exportLayoutToFile(layout: FieldLayoutData, filename = 'fll-competition-layout.json'): void {
    const blob = new Blob([JSON.stringify(layout, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }
 
  /**
   * Reads and parses a layout JSON file from local disk
   */
  public static async importLayoutFromFile(file: File): Promise<FieldLayoutData> {
    const text = await file.text();
    const data = JSON.parse(text) as FieldLayoutData;
    if (!data.elements || !Array.isArray(data.elements)) {
      throw new Error('Invalid layout file format: missing elements array.');
    }
    return data;
  }
 
  /**
   * Applies layout coordinates and Dual Lock anchor states across MissionManager elements
   */
  public static async applyLayout(
    layout: FieldLayoutData,
    missionManager: MissionManager
  ): Promise<{ restoredCount: number; dualLockedCount: number }> {
    let restoredCount = 0;
    let dualLockedCount = 0;
 
    for (const entry of layout.elements) {
      let elem = missionManager.getElement(entry.elementId);
 
      // If the parent mission is not currently loaded, attempt to load it
      if (!elem && entry.parentMissionId && entry.parentMissionId === entry.elementId) {
        await missionManager.toggleSeasonMission(entry.elementId, true);
        elem = missionManager.getElement(entry.elementId);
      }
 
      if (elem) {
        // Set transform
        elem.setPosition(entry.position, entry.yawDegrees);
 
        // Apply Dual Lock status
        if (elem.setDualLocked) {
          elem.setDualLocked(entry.isDualLocked, entry.dualLockPosition || undefined, entry.anchoredClusterId || undefined);
        } else if (elem instanceof CustomImportedMissionElement) {
          elem.setDualLocked(entry.isDualLocked, entry.dualLockPosition || undefined, entry.anchoredClusterId || undefined);
        }
 
        if (entry.isDualLocked) {
          dualLockedCount++;
        }
        restoredCount++;
      }
    }
 
    missionManager.onMissionListChanged?.();
    return { restoredCount, dualLockedCount };
  }
}