diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index aed1683..716b648 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -772,10 +772,10 @@ func (s *Service) LogProjectileEvent(data []string) (model.ProjectileEvent, erro continue } - // posArr[0] = tickTime (float) - tickTime, ok := posArr[0].(float64) + // posArr[1] = frameNo (float64 from JSON) + frameNo, ok := posArr[1].(float64) if !ok { - logger.Warn("Invalid tickTime in position", "value", posArr[0]) + logger.Warn("Invalid frameNo in position", "value", posArr[1]) continue } @@ -793,12 +793,13 @@ func (s *Service) LogProjectileEvent(data []string) (model.ProjectileEvent, erro } coords, _ := point.Coordinates() + // Store as XYZM where M = frame number (used by projectile markers) positionSequence = append( positionSequence, coords.XY.X, coords.XY.Y, coords.Z, - tickTime, + frameNo, ) } diff --git a/internal/model/convert/convert.go b/internal/model/convert/convert.go index 0485523..2308ac4 100644 --- a/internal/model/convert/convert.go +++ b/internal/model/convert/convert.go @@ -3,6 +3,7 @@ package convert import ( "encoding/json" + "fmt" "github.com/OCAP2/extension/v5/internal/model" "github.com/OCAP2/extension/v5/internal/model/core" @@ -340,6 +341,7 @@ func MarkerToCore(m model.Marker) core.Marker { MissionID: m.MissionID, Time: m.Time, CaptureFrame: m.CaptureFrame, + EndFrame: -1, // Regular markers persist until end MarkerName: m.MarkerName, Direction: m.Direction, MarkerType: m.MarkerType, @@ -405,6 +407,105 @@ func ProjectileEventToFiredEvent(p model.ProjectileEvent) core.FiredEvent { } } +// ProjectileEventToProjectileMarker converts a thrown projectile (grenade, smoke) to a marker +// for the web viewer. Returns the marker and any state changes for trajectory positions. +// Only call this for projectiles where Weapon == "throw". +func ProjectileEventToProjectileMarker(p model.ProjectileEvent) (core.Marker, []core.MarkerState) { + // Extract filename from icon path: "\A3\...\gear_smokegrenade_white_ca.paa" → "magIcons/gear_smokegrenade_white_ca.paa" + // Handle both forward and backslash separators (Arma uses backslashes) + iconPath := p.MagazineIcon + // Find last separator (either / or \) + lastSep := -1 + for i := len(iconPath) - 1; i >= 0; i-- { + if iconPath[i] == '/' || iconPath[i] == '\\' { + lastSep = i + break + } + } + var iconFilename string + if lastSep >= 0 { + iconFilename = iconPath[lastSep+1:] + } else { + iconFilename = iconPath + } + markerType := "magIcons/" + iconFilename + if iconFilename == "" { + // Fallback for missing icon + markerType = "magIcons/gear_unknown_ca.paa" + } + + // Generate unique marker name and ID + // ID combines frame and firer to ensure uniqueness + markerName := fmt.Sprintf("projectile_%d_%d", p.CaptureFrame, p.FirerObjectID) + markerID := uint(p.CaptureFrame)<<16 | uint(p.FirerObjectID) + + var positions []core.Position3D + var frames []uint + + // Extract positions from the LineStringZM geometry + // Format: [x, y, z, frameNo] where M coordinate contains the frame number + if !p.Positions.IsEmpty() { + if ls, ok := p.Positions.AsLineString(); ok { + seq := ls.Coordinates() + for i := 0; i < seq.Length(); i++ { + pt := seq.Get(i) + positions = append(positions, core.Position3D{X: pt.X, Y: pt.Y, Z: pt.Z}) + // M coordinate contains the frame number + frames = append(frames, uint(pt.M)) + } + } + } + + // Use first position for the marker, rest become states + var firstPos core.Position3D + if len(positions) > 0 { + firstPos = positions[0] + } + + // EndFrame is the last position's frame (when grenade explodes/dissipates) + endFrame := -1 + if len(frames) > 0 { + endFrame = int(frames[len(frames)-1]) + } + + marker := core.Marker{ + ID: markerID, + MissionID: p.MissionID, + CaptureFrame: p.CaptureFrame, + EndFrame: endFrame, + MarkerName: markerName, + MarkerType: markerType, + Text: p.MagazineDisplay, + OwnerID: int(p.FirerObjectID), + Color: "FFFFFF", + Side: "GLOBAL", + Position: firstPos, + Size: "[1,1]", + Shape: "ICON", + Alpha: 1.0, + Brush: "Solid", + } + + // Create states for remaining positions (trajectory) + var states []core.MarkerState + for i := 1; i < len(positions); i++ { + frame := frames[i] + if i < len(frames) { + frame = frames[i] + } + states = append(states, core.MarkerState{ + MarkerID: markerID, + MissionID: p.MissionID, + CaptureFrame: frame, + Position: positions[i], + Direction: 0, + Alpha: 1.0, + }) + } + + return marker, states +} + // MissionToCore converts a GORM Mission to a core.Mission func MissionToCore(m *model.Mission) core.Mission { addons := make([]core.Addon, 0, len(m.Addons)) diff --git a/internal/model/convert/convert_test.go b/internal/model/convert/convert_test.go index f4095d8..e5f0fa2 100644 --- a/internal/model/convert/convert_test.go +++ b/internal/model/convert/convert_test.go @@ -705,6 +705,106 @@ func TestProjectileEventToFiredEvent_EmptyPositions(t *testing.T) { } } +func TestProjectileEventToProjectileMarker(t *testing.T) { + // Create a LineStringZM with 3 points (thrown, mid-flight, impact) + // Format: [x, y, z, frameNo] where M = frame number + coords := []float64{ + 100.0, 200.0, 10.0, 243.0, // thrown position at frame 243 + 150.0, 250.0, 15.0, 245.0, // mid-flight at frame 245 + 200.0, 300.0, 5.0, 303.0, // impact position at frame 303 + } + seq := geom.NewSequence(coords, geom.DimXYZM) + ls, _ := geom.NewLineString(seq) + + gormEvent := model.ProjectileEvent{ + MissionID: 1, + FirerObjectID: 42, + CaptureFrame: 100, + Weapon: "throw", + MagazineDisplay: "Smoke Grenade (White)", + MagazineIcon: `\A3\Weapons_F\Data\UI\gear_smokegrenade_white_ca.paa`, + Positions: ls.AsGeometry(), + } + + marker, states := ProjectileEventToProjectileMarker(gormEvent) + + // Check marker fields + if marker.MarkerType != "magIcons/gear_smokegrenade_white_ca.paa" { + t.Errorf("expected MarkerType=magIcons/gear_smokegrenade_white_ca.paa, got %s", marker.MarkerType) + } + if marker.Text != "Smoke Grenade (White)" { + t.Errorf("expected Text=Smoke Grenade (White), got %s", marker.Text) + } + if marker.OwnerID != 42 { + t.Errorf("expected OwnerID=42, got %d", marker.OwnerID) + } + if marker.Side != "GLOBAL" { + t.Errorf("expected Side=GLOBAL, got %s", marker.Side) + } + if marker.Shape != "ICON" { + t.Errorf("expected Shape=ICON, got %s", marker.Shape) + } + if marker.MarkerName != "projectile_100_42" { + t.Errorf("expected MarkerName=projectile_100_42, got %s", marker.MarkerName) + } + + // EndFrame should be the last position's frame (303) + if marker.EndFrame != 303 { + t.Errorf("expected EndFrame=303, got %d", marker.EndFrame) + } + + // First position should be in marker + if marker.Position.X != 100.0 || marker.Position.Y != 200.0 { + t.Errorf("expected marker Position=(100,200), got (%f,%f)", + marker.Position.X, marker.Position.Y) + } + + // Remaining positions should be in states + if len(states) != 2 { + t.Fatalf("expected 2 states, got %d", len(states)) + } + if states[0].Position.X != 150.0 || states[0].Position.Y != 250.0 { + t.Errorf("expected state[0] Position=(150,250), got (%f,%f)", + states[0].Position.X, states[0].Position.Y) + } + if states[1].Position.X != 200.0 || states[1].Position.Y != 300.0 { + t.Errorf("expected state[1] Position=(200,300), got (%f,%f)", + states[1].Position.X, states[1].Position.Y) + } + + // States should have correct frame numbers from M coordinate + if states[0].CaptureFrame != 245 { + t.Errorf("expected state[0] CaptureFrame=245, got %d", states[0].CaptureFrame) + } + if states[1].CaptureFrame != 303 { + t.Errorf("expected state[1] CaptureFrame=303, got %d", states[1].CaptureFrame) + } + + // States should reference marker ID + if states[0].MarkerID != marker.ID { + t.Errorf("expected state MarkerID=%d, got %d", marker.ID, states[0].MarkerID) + } +} + +func TestProjectileEventToProjectileMarker_EmptyIcon(t *testing.T) { + gormEvent := model.ProjectileEvent{ + MissionID: 1, + FirerObjectID: 5, + CaptureFrame: 50, + Weapon: "throw", + MagazineDisplay: "Unknown Grenade", + MagazineIcon: "", // empty icon + Positions: geom.Geometry{}, + } + + marker, _ := ProjectileEventToProjectileMarker(gormEvent) + + // Should use fallback icon + if marker.MarkerType != "magIcons/gear_unknown_ca.paa" { + t.Errorf("expected fallback MarkerType=magIcons/gear_unknown_ca.paa, got %s", marker.MarkerType) + } +} + // Compile-time interface checks var ( _ core.Soldier = SoldierToCore(model.Soldier{}) diff --git a/internal/model/core/marker.go b/internal/model/core/marker.go index 4f2343c..46b55c2 100644 --- a/internal/model/core/marker.go +++ b/internal/model/core/marker.go @@ -9,6 +9,7 @@ type Marker struct { MissionID uint Time time.Time CaptureFrame uint + EndFrame int // -1 means persist until end, otherwise frame when marker disappears MarkerName string Direction float32 MarkerType string diff --git a/internal/storage/memory/export/v1/builder.go b/internal/storage/memory/export/v1/builder.go index d0cf812..0ebd0fb 100644 --- a/internal/storage/memory/export/v1/builder.go +++ b/internal/storage/memory/export/v1/builder.go @@ -297,11 +297,17 @@ func Build(data *MissionData) Export { // With "#" prefix, browsers interpret the fragment as an anchor, causing 404s markerColor := strings.TrimPrefix(record.Marker.Color, "#") + // EndFrame: 0 means not set (use -1 to persist), positive means specific end frame + endFrame := record.Marker.EndFrame + if endFrame == 0 { + endFrame = -1 + } + marker := []any{ record.Marker.MarkerType, // [0] type record.Marker.Text, // [1] text record.Marker.CaptureFrame, // [2] startFrame - -1, // [3] endFrame (-1 = persists until end) + endFrame, // [3] endFrame (-1 = persists until end, otherwise frame when marker disappears) record.Marker.OwnerID, // [4] playerId (entity ID of creating player, -1 for system markers) markerColor, // [5] color (# prefix stripped for URL compatibility) sideToIndex(record.Marker.Side), // [6] sideIndex diff --git a/internal/worker/dispatch.go b/internal/worker/dispatch.go index c500f8a..de89eaa 100644 --- a/internal/worker/dispatch.go +++ b/internal/worker/dispatch.go @@ -149,14 +149,25 @@ func (m *Manager) handleFiredEvent(e dispatcher.Event) (any, error) { } func (m *Manager) handleProjectileEvent(e dispatcher.Event) (any, error) { - // For memory backend, convert projectile to fired event (simpler format) + // For memory backend, convert projectile to appropriate format if m.hasBackend() { obj, err := m.deps.HandlerService.LogProjectileEvent(e.Args) if err != nil { return nil, fmt.Errorf("failed to log projectile event: %w", err) } - coreObj := convert.ProjectileEventToFiredEvent(obj) - m.backend.RecordFiredEvent(&coreObj) + + // Thrown projectiles (grenades, smokes) become markers + if obj.Weapon == "throw" { + marker, states := convert.ProjectileEventToProjectileMarker(obj) + m.backend.AddMarker(&marker) + for i := range states { + m.backend.RecordMarkerState(&states[i]) + } + } else { + // Other projectiles become fire lines + coreObj := convert.ProjectileEventToFiredEvent(obj) + m.backend.RecordFiredEvent(&coreObj) + } return nil, nil }