├── README.md ├── example.png ├── spraypatternextractor.go └── spraypatternplotter.py /README.md: -------------------------------------------------------------------------------- 1 | # csgo_spray_pattern_plotter 2 | 3 | A tool to extract and plot spray patterns from CS:GO replays 4 | 5 | ![Example](example.png) 6 | 7 | # Prerequisites 8 | 9 | * Python3 10 | * Golang 11 | 12 | # Example usage 13 | 14 | ## Spray data extraction 15 | 16 | Extract data from a replay file and store it as comma separated values 17 | 18 | ``` 19 | go get -u github.com/markus-wa/demoinfocs-golang 20 | go run spraypatternextractor.go -demo=natus-vincere-vs-big-m1-dust2.dem > natus-vincere-vs-big-m1-dust2.csv 21 | ``` 22 | 23 | ## Spray pattern plotting 24 | 25 | Use the comma separated values to create plots using python 26 | 27 | ``` 28 | python spraypatternplotter.py --csv natus-vincere-vs-big-m1-dust2.csv 29 | ``` 30 | 31 | # Contact 32 | 33 | https://steamcommunity.com/id/dlxDaniel/ 34 | 35 | (or find my email) 36 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o40/csgo_spray_pattern_plotter/a5fa6429d3172b8f77e70b7bdfcb07b8e733c1c8/example.png -------------------------------------------------------------------------------- /spraypatternextractor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "flag" 7 | "sort" 8 | 9 | dem "github.com/markus-wa/demoinfocs-golang" 10 | events "github.com/markus-wa/demoinfocs-golang/events" 11 | ) 12 | 13 | // Settings 14 | const numPaddingFrames = 15 15 | 16 | type WeaponFiredData struct { 17 | IngameTick int 18 | WeaponId int 19 | } 20 | 21 | type ViewDirectionData struct { 22 | IngameTick int 23 | WeaponId int 24 | ViewDirectionX float32 25 | ViewDirectionY float32 26 | } 27 | 28 | type SprayData struct { 29 | WeaponFired bool 30 | WeaponHit bool 31 | Kill bool 32 | WeaponId int 33 | ViewDirectionX float32 34 | ViewDirectionY float32 35 | } 36 | 37 | func outputSprayPatternAsCsv(parser *dem.Parser) { 38 | 39 | // Store view direction, weapon id and tick for all ticks where a weapon was fired (per player) 40 | weaponFiredDataPerPlayer := make(map[string][]WeaponFiredData) 41 | parser.RegisterEventHandler(func(e events.WeaponFire) { 42 | // Only check rifles 43 | if (e.Weapon.Weapon >= 300 && e.Weapon.Weapon < 400) { 44 | var weaponFiredData WeaponFiredData 45 | weaponFiredData.IngameTick = parser.GameState().IngameTick() 46 | weaponFiredData.WeaponId = int(e.Weapon.Weapon) 47 | weaponFiredDataPerPlayer[e.Shooter.Name] = append(weaponFiredDataPerPlayer[e.Shooter.Name], weaponFiredData) 48 | } 49 | }) 50 | 51 | // Get ticks where a player hurts another player 52 | weaponHitDataPerPlayer := make(map[string][]int) 53 | parser.RegisterEventHandler(func(e events.PlayerHurt) { 54 | if e.Attacker != nil && e.Player != nil { 55 | weaponHitDataPerPlayer[e.Attacker.Name] = append(weaponHitDataPerPlayer[e.Attacker.Name], parser.GameState().IngameTick()) 56 | } 57 | }) 58 | 59 | // Get ticks where a player kills another player 60 | killTicksPerPlayer := make(map[string][]int) 61 | parser.RegisterEventHandler(func(e events.Kill) { 62 | if e.Killer != nil { 63 | killTicksPerPlayer[e.Killer.Name] = append(killTicksPerPlayer[e.Killer.Name], parser.GameState().IngameTick()) 64 | } 65 | }) 66 | 67 | // Get player view angles per tick and store per player 68 | viewDirectionDataPerPlayer := make(map[string][]ViewDirectionData) 69 | parser.RegisterEventHandler(func(events.TickDone) { 70 | players := parser.GameState().Participants().Playing() 71 | for _, player := range players { 72 | if player != nil { 73 | var viewDirectionData ViewDirectionData 74 | viewDirectionData.IngameTick = parser.GameState().IngameTick() 75 | viewDirectionData.ViewDirectionX = player.ViewDirectionX 76 | viewDirectionData.ViewDirectionY = player.ViewDirectionY 77 | viewDirectionData.WeaponId = player.ActiveWeaponID 78 | viewDirectionDataPerPlayer[player.Name] = append(viewDirectionDataPerPlayer[player.Name], viewDirectionData) 79 | } 80 | } 81 | }) 82 | 83 | // Run parser (to populate weaponFiredData and viewDirectionData) 84 | parser.ParseToEnd() 85 | 86 | // Dump csv data per player 87 | for playerKey, weaponFiredSlice := range weaponFiredDataPerPlayer { 88 | 89 | // Save spray data per tick in this inner loop 90 | sprayDataPerTick := make(map[int]SprayData) 91 | 92 | // Loop over ticks with fired weapons and add those position + padding ticks 93 | // to create a list of view directions per tick. Init weapon fired to false. 94 | for _, weaponFiredData := range weaponFiredSlice { 95 | for _, viewDirectionData := range viewDirectionDataPerPlayer[playerKey] { 96 | if (viewDirectionData.IngameTick >= weaponFiredData.IngameTick && 97 | viewDirectionData.IngameTick <= weaponFiredData.IngameTick + numPaddingFrames) { 98 | var sprayData SprayData 99 | sprayData.WeaponFired = false 100 | sprayData.WeaponHit = false 101 | sprayData.Kill = false 102 | sprayData.ViewDirectionX = viewDirectionData.ViewDirectionX 103 | sprayData.ViewDirectionY = viewDirectionData.ViewDirectionY 104 | sprayData.WeaponId = weaponFiredData.WeaponId 105 | sprayDataPerTick[viewDirectionData.IngameTick] = sprayData 106 | } 107 | } 108 | } 109 | 110 | // Set WeaponFired to true in the spray data for the ticks where a weapon was fired 111 | for _, weaponFiredData := range weaponFiredSlice { 112 | var sprayData = sprayDataPerTick[weaponFiredData.IngameTick] 113 | sprayData.WeaponFired = true 114 | sprayDataPerTick[weaponFiredData.IngameTick] = sprayData 115 | } 116 | 117 | // Set WeaponHit to true for all ticks where a player hit the shot 118 | for _, weaponHitTick := range weaponHitDataPerPlayer[playerKey] { 119 | var sprayData = sprayDataPerTick[weaponHitTick] 120 | sprayData.WeaponHit = true 121 | sprayDataPerTick[weaponHitTick] = sprayData 122 | } 123 | 124 | // Set WeaponHit to true for all ticks where a player hit the shot 125 | for _, killTick := range killTicksPerPlayer[playerKey] { 126 | var sprayData = sprayDataPerTick[killTick] 127 | sprayData.Kill = true 128 | sprayDataPerTick[killTick] = sprayData 129 | } 130 | 131 | // Get the tick "keys" and sort them 132 | var keys []int 133 | for k := range sprayDataPerTick { 134 | keys = append(keys, k) 135 | } 136 | sort.Ints(keys) 137 | 138 | // Print the spray data per tick in order 139 | for _, tick := range keys { 140 | var sprayData = sprayDataPerTick[tick] 141 | weaponFired := 0 142 | weaponHit := 0 143 | kill := 0 144 | 145 | if (sprayData.WeaponFired) { 146 | weaponFired = 1 147 | } 148 | 149 | if (sprayData.WeaponHit) { 150 | weaponHit = 1 151 | } 152 | 153 | if (sprayData.Kill) { 154 | kill = 1 155 | } 156 | 157 | if (sprayData.Kill || sprayData.WeaponHit) && !sprayData.WeaponFired { 158 | continue 159 | } 160 | 161 | fmt.Printf("%s,%d,%d,%d,%d,%f,%f,%d\n", 162 | playerKey, 163 | tick, 164 | weaponFired, 165 | weaponHit, 166 | kill, 167 | sprayData.ViewDirectionX, 168 | sprayData.ViewDirectionY, 169 | sprayData.WeaponId) 170 | } 171 | } 172 | } 173 | 174 | func main() { 175 | demoPathPtr := flag.String("demo", "", "Path to the demo") 176 | flag.Parse() 177 | 178 | // Mandatory argument 179 | if *demoPathPtr == "" { 180 | fmt.Printf("Missing argument demo\n") 181 | os.Exit(1) 182 | } 183 | 184 | if _, err := os.Stat(*demoPathPtr); os.IsNotExist(err) { 185 | fmt.Printf("%s does not exist\n", *demoPathPtr) 186 | os.Exit(1) 187 | } 188 | 189 | f, err := os.Open(*demoPathPtr) 190 | if err != nil { 191 | panic(err) 192 | } 193 | defer f.Close() 194 | 195 | p := dem.NewParser(f) 196 | 197 | outputSprayPatternAsCsv(p) 198 | } -------------------------------------------------------------------------------- /spraypatternplotter.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from pathlib import Path 3 | from recordtype import recordtype 4 | import argparse 5 | import collections 6 | import itertools 7 | import math 8 | import matplotlib.pyplot as plt 9 | import sys 10 | 11 | plt.style.use('Solarize_Light2') 12 | 13 | Points = recordtype('Points', ['x', 'y']) 14 | Spray = recordtype('Spray', ['player', 'weapon', 'first_shot_tick', 'view_angles', 'shots', 'hits', 'kills']) 15 | 16 | 17 | def weapon_id_to_string(id): 18 | weapons = { 19 | 301: "Galil", 20 | 302: "Famas", 21 | 303: "AK47", 22 | 304: "M4A4", 23 | 305: "M4A1", 24 | 306: "Scout", 25 | 307: "SG553", 26 | 308: "AUG", 27 | 309: "AWP", 28 | 310: "Scar20", 29 | 311: "G3SG1" 30 | } 31 | return weapons.get(id, "Unknown") 32 | 33 | 34 | def should_adjust_horizontal_angle(points): 35 | ''' 36 | Adjust horizontal angles to avoid plotting over the 360 -> 0 gap 37 | ''' 38 | horizontal_min = min(points) 39 | horizontal_max = max(points) 40 | return horizontal_min < 20 and horizontal_max > 360 - 20 41 | 42 | 43 | def adjust_horizontal_angles(angles): 44 | return [((angle + 180) % 360) for angle in angles] 45 | 46 | 47 | def adjust_vertical_angle(angle): 48 | ''' 49 | Vertical angles seem to be stored 270 -> 360 from down to "forward", and then 50 | 0 -> 90 from "forward" to up. Adjust this to be able to plot. 51 | ''' 52 | return (angle - 360) if angle > 180 else angle 53 | 54 | 55 | def plot_line(axes, points): 56 | ''' 57 | Plot the points as line segments (with alternating colors) 58 | ''' 59 | line_color_toggle = False 60 | for x1, x2, y1, y2 in zip(points.x[:-1], points.x[1:], points.y[:-1], points.y[1:]): 61 | 62 | # Plot a point if no movement 63 | if x1 == x2 and y1 == y2: 64 | axes.scatter(x1, y1, c="white", alpha=1, zorder=2, s=4) 65 | continue 66 | 67 | line_color = 'darkgrey' 68 | if line_color_toggle: 69 | line_color = 'lightgrey' 70 | 71 | line_color_toggle ^= True 72 | axes.plot([x1, x2], [y1, y2], '-', c=line_color, linewidth=2, zorder=1) 73 | 74 | 75 | def plot_shots(axes, shots, hits, kills): 76 | ''' 77 | Scatter plot the shots and add labels (numbering) for them 78 | ''' 79 | marker_size = 80 80 | axes.scatter(shots.x, shots.y, c="goldenrod", alpha=1, zorder=2, s=marker_size) 81 | axes.scatter(hits.x, hits.y, c="lime", alpha=1, zorder=2, s=marker_size) 82 | axes.scatter(kills.x, kills.y, c="red", alpha=1, zorder=2, s=marker_size) 83 | 84 | shotnum = 1 85 | for x, y in zip(shots.x, shots.y): 86 | axes.text(x, y, f'{shotnum}', 87 | horizontalalignment='center', 88 | verticalalignment='center', 89 | fontsize=8, alpha=1, zorder=3) 90 | shotnum += 1 91 | 92 | 93 | def plot_spray(spray, 94 | csv_file, 95 | output_folder): 96 | 97 | fig, axes = plt.subplots(1, 1, figsize=(10, 8)) 98 | 99 | # Adjust angles for plotting purposes 100 | if should_adjust_horizontal_angle(spray.view_angles.x): 101 | spray.view_angles.x = adjust_horizontal_angles(spray.view_angles.x) 102 | spray.shots.x = adjust_horizontal_angles(spray.shots.x) 103 | spray.hits.x = adjust_horizontal_angles(spray.hits.x) 104 | spray.kills.x = adjust_horizontal_angles(spray.kills.x) 105 | 106 | # Plot the view angles as a line 107 | plot_line(axes, spray.view_angles) 108 | 109 | # Plot the shots as numbered points 110 | plot_shots(axes, spray.shots, spray.hits, spray.kills) 111 | 112 | weapon_str = weapon_id_to_string(spray.weapon) 113 | replay_name = Path(csv_file).stem 114 | axes.set_title(f"Game: {replay_name}.dem\n" 115 | f"Tick: {spray.first_shot_tick}, Player: {spray.player}, Weapon: {weapon_str}") 116 | axes.set_xlabel("yaw (degrees)") 117 | axes.set_ylabel("pitch (degrees)") 118 | 119 | # CS:GO left and up is negative direction. Invert to plot correctly. 120 | axes.invert_xaxis() 121 | axes.invert_yaxis() 122 | 123 | filename = f"{output_folder}/{replay_name}_{spray.player}_{str(spray.first_shot_tick).zfill(7)}.png" 124 | plt.savefig(filename, facecolor=fig.get_facecolor()) 125 | plt.close() 126 | 127 | 128 | def parse_args(): 129 | parser = argparse.ArgumentParser() 130 | parser.add_argument("--csv", required=True) 131 | parser.add_argument("--filter", required=False) 132 | parser.add_argument("--test", required=False, action='store_true') 133 | parser.add_argument("--tick", required=False) 134 | parser.add_argument("--out", required=False, default='out') 135 | return parser.parse_args() 136 | 137 | 138 | def main(): 139 | 140 | args = parse_args() 141 | 142 | # Settings 143 | segment_margin = 20 144 | min_shots_for_plot = 4 145 | 146 | sprays = [] 147 | 148 | prev_tick = None 149 | 150 | with open(args.csv) as rawfile: 151 | 152 | spray = Spray(None, None, None, Points([], []), Points([], []), Points([], []), Points([], [])) 153 | 154 | # Add view angles in chunks to avoid view angles to be plotted after last shot 155 | view_angles_temp = Points([], []) 156 | 157 | for index, row in enumerate(rawfile): 158 | player, tick, shot, hit, kill, x, y, weapon = row.split(',') 159 | tick, shot, hit, kill, weapon = int(tick), int(shot), int(hit), int(kill), int(weapon) 160 | x, y = float(x), float(y) 161 | 162 | y = adjust_vertical_angle(y) 163 | 164 | if args.filter and args.filter != player: 165 | continue 166 | 167 | # Check in jump of tick. In that case store spray and continue 168 | if prev_tick: 169 | if abs(tick - prev_tick) > segment_margin: 170 | sprays.append(spray) 171 | spray = Spray(None, None, None, Points([], []), Points([], []), Points([], []), Points([], [])) 172 | view_angles_temp = Points([], []) 173 | prev_tick = tick 174 | 175 | view_angles_temp.x.append(x) 176 | view_angles_temp.y.append(y) 177 | 178 | if shot: 179 | spray.view_angles.x.extend(view_angles_temp.x) 180 | spray.view_angles.y.extend(view_angles_temp.y) 181 | view_angles_temp = Points([], []) 182 | 183 | if spray.player is None: 184 | spray.player = player 185 | 186 | if spray.weapon is None: 187 | spray.weapon = weapon 188 | 189 | if spray.first_shot_tick is None: 190 | spray.first_shot_tick = tick 191 | 192 | spray.shots.x.append(x) 193 | spray.shots.y.append(y) 194 | 195 | if hit: 196 | spray.hits.x.append(x) 197 | spray.hits.y.append(y) 198 | 199 | if kill: 200 | spray.kills.x.append(x) 201 | spray.kills.y.append(y) 202 | sprays.append(spray) 203 | 204 | for spray in sprays: 205 | if args.tick and (int(args.tick) != spray.first_shot_tick): 206 | continue 207 | if len(spray.shots.x) >= min_shots_for_plot: 208 | plot_spray(spray, args.csv, args.out) 209 | if args.test: 210 | print("Returning early for testing") 211 | sys.exit(0) 212 | 213 | 214 | if __name__ == '__main__': 215 | main() 216 | --------------------------------------------------------------------------------