Skip to content

Commit 4774f93

Browse files
authored
feature: enhance stats (#2)
1 parent 485891c commit 4774f93

2 files changed

Lines changed: 130 additions & 187 deletions

File tree

LEETIFY_STATS_PROGRESS.md

Lines changed: 0 additions & 185 deletions
This file was deleted.

internal/parser/parser.go

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ package parser
1414
import (
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

113119
type 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.
191249
func 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

Comments
 (0)