@@ -14,11 +14,13 @@ package parser
1414import (
1515 "fmt"
1616 "io"
17+ "math"
1718 "os"
1819 "regexp"
1920 "sort"
2021 "strconv"
2122
23+ "github.com/golang/geo/r3"
2224 dem "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs"
2325 "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/common"
2426 "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/events"
@@ -108,6 +110,10 @@ type EventShotFired struct {
108110 AttackerSteamID string `json:"attacker,omitempty"`
109111 AttackerTeam string `json:"attacker_team,omitempty"`
110112 Weapon string `json:"weapon,omitempty"`
113+ // Pointers so we can distinguish "no frame snapshot" from "zero".
114+ Speed * float32 `json:"speed,omitempty"`
115+ CounterStrafed * bool `json:"counter_strafed,omitempty"`
116+ CrosshairAngleDeg * float32 `json:"crosshair_angle_deg,omitempty"`
111117}
112118
113119type EventDamage struct {
@@ -186,6 +192,58 @@ type Result struct {
186192 GrenadeDetonations []EventGrenadeDetonate `json:"grenade_detonations,omitempty"`
187193}
188194
195+ type playerFrame struct {
196+ pos r3.Vector
197+ speed float32
198+ team common.Team
199+ alive bool
200+ }
201+
202+ // Source 2 pawn velocity. PropertyValue.R3Vec() panics on unexpected
203+ // shapes; defer/recover keeps a malformed entity from killing the parse.
204+ func pawnVelocity (p * common.Player ) (r3.Vector , bool ) {
205+ if p == nil {
206+ return r3.Vector {}, false
207+ }
208+ pawn := p .PlayerPawnEntity ()
209+ if pawn == nil {
210+ return r3.Vector {}, false
211+ }
212+ pv , ok := pawn .PropertyValue ("m_vecVelocity" )
213+ if ! ok {
214+ return r3.Vector {}, false
215+ }
216+ defer func () { _ = recover () }()
217+ return pv .R3Vec (), true
218+ }
219+
220+ func angleBetweenDeg (a , b r3.Vector ) float32 {
221+ la := math .Sqrt (a .X * a .X + a .Y * a .Y + a .Z * a .Z )
222+ lb := math .Sqrt (b .X * b .X + b .Y * b .Y + b .Z * b .Z )
223+ if la == 0 || lb == 0 {
224+ return 180
225+ }
226+ cos := (a .X * b .X + a .Y * b .Y + a .Z * b .Z ) / (la * lb )
227+ if cos > 1 {
228+ cos = 1
229+ } else if cos < - 1 {
230+ cos = - 1
231+ }
232+ return float32 (math .Acos (cos ) * 180 / math .Pi )
233+ }
234+
235+ // CS eye angles → unit vector. Z-up; pitch>0 looks down.
236+ func viewVector (yawDeg , pitchDeg float32 ) r3.Vector {
237+ yaw := float64 (yawDeg ) * math .Pi / 180
238+ pitch := float64 (pitchDeg ) * math .Pi / 180
239+ cp := math .Cos (pitch )
240+ return r3.Vector {
241+ X : cp * math .Cos (yaw ),
242+ Y : cp * math .Sin (yaw ),
243+ Z : - math .Sin (pitch ),
244+ }
245+ }
246+
189247// grenadeTypeCode maps an EquipmentType to the wire string used in
190248// EventGrenadeThrow / EventGrenadeDetonate. Returns "" for non-grenades.
191249func grenadeTypeCode (t common.EquipmentType ) string {
@@ -256,6 +314,8 @@ func Parse(r io.Reader) (*Result, error) {
256314 // sight is implicit and would just spam the timeline.
257315 seenSpotters := map [string ]map [string ]struct {}{}
258316
317+ frames := map [string ]playerFrame {}
318+
259319 // Accumulate player names as we observe them via kill events. Map
260320 // here for de-dup; we flatten to the slice form on the Result at
261321 // the very end. Skipping bots (no real steam_id) and the empty
@@ -448,6 +508,33 @@ func Parse(r io.Reader) (*Result, error) {
448508 })
449509 })
450510
511+ parser .RegisterEventHandler (func (_ events.FrameDone ) {
512+ if ! matchStarted {
513+ return
514+ }
515+ clear (frames )
516+ for _ , p := range parser .GameState ().Participants ().Playing () {
517+ if p == nil || ! p .IsAlive () {
518+ continue
519+ }
520+ sid := steamIDStr (p )
521+ if sid == "" {
522+ continue
523+ }
524+ vel , ok := pawnVelocity (p )
525+ speed := float32 (0 )
526+ if ok {
527+ speed = float32 (math .Sqrt (vel .X * vel .X + vel .Y * vel .Y ))
528+ }
529+ frames [sid ] = playerFrame {
530+ pos : p .Position (),
531+ speed : speed ,
532+ team : p .Team ,
533+ alive : true ,
534+ }
535+ }
536+ })
537+
451538 // WeaponFire — one row per shot. Filter to firearm classes only:
452539 // knives and grenade "fires" would balloon the array and don't
453540 // participate in accuracy metrics. demoinfocs's EquipmentClass
@@ -463,13 +550,54 @@ func Parse(r io.Reader) (*Result, error) {
463550 default :
464551 return
465552 }
466- res . ShotsFired = append ( res . ShotsFired , EventShotFired {
553+ ev := EventShotFired {
467554 Tick : parser .GameState ().IngameTick (),
468555 Round : currentRound ,
469556 AttackerSteamID : steamIDStr (e .Shooter ),
470557 AttackerTeam : teamCode (e .Shooter .Team ),
471558 Weapon : e .Weapon .String (),
472- })
559+ }
560+
561+ if sf , ok := frames [ev .AttackerSteamID ]; ok && sf .alive {
562+ speed := sf .speed
563+ // CS2 movement-accuracy floor.
564+ counter := speed < 5
565+ ev .Speed = & speed
566+ ev .CounterStrafed = & counter
567+
568+ var (
569+ bestDist = math .Inf (1 )
570+ bestEnemy r3.Vector
571+ haveEnemy bool
572+ )
573+ for sid , ef := range frames {
574+ if sid == ev .AttackerSteamID || ! ef .alive {
575+ continue
576+ }
577+ if ef .team == sf .team || ef .team == common .TeamUnassigned || ef .team == common .TeamSpectators {
578+ continue
579+ }
580+ d := ef .pos .Sub (sf .pos )
581+ dist := d .X * d .X + d .Y * d .Y + d .Z * d .Z
582+ if dist < bestDist {
583+ bestDist = dist
584+ bestEnemy = ef .pos
585+ haveEnemy = true
586+ }
587+ }
588+ if haveEnemy {
589+ eyes , eok := e .Shooter .PositionEyes ()
590+ if ! eok {
591+ eyes = sf .pos
592+ }
593+ view := viewVector (e .Shooter .ViewDirectionX (), e .Shooter .ViewDirectionY ())
594+ toEnemy := bestEnemy .Sub (eyes )
595+ angle := angleBetweenDeg (view , toEnemy )
596+ ev .CrosshairAngleDeg = & angle
597+ }
598+ }
599+
600+ res .ShotsFired = append (res .ShotsFired , ev )
473601 })
474602
475603 // PlayerHurt — one row per damage instance. Skip self-damage
0 commit comments