Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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,
)
}

Expand Down
101 changes: 101 additions & 0 deletions internal/model/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
100 changes: 100 additions & 0 deletions internal/model/convert/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
1 change: 1 addition & 0 deletions internal/model/core/marker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion internal/storage/memory/export/v1/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions internal/worker/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading