안녕하세요 배틀그라운드 TMI 지표에서 "운"과 관련하여 자기장 데이터를 전처리한 적이 있는데요. 이와 관련해서 질문을 주신게 있어서 정리해보려고 합니다.
목표는 페이즈별 SafetyZone
테이블을 구하는 것 입니다! 보통 게임을 할 때는 자기장 영역 (BlueZone)이 바뀌었다고 하는데요! 아래의 사진처럼 파란색부분이 모두 자기장영역이며, 페이즈별로 점점 줄어드는 흰색 원안의 영역이 SafetyZone
입니다. 사실 SafetyZone이 바뀌는 것이죠. 데이터에서도 SafetyZone을 기준으로 데이터가 남겨지고있습니다.
그러므로 해당 포스팅에선 편의상 페이즈별 SafetyZone 테이블을 구하는 것으로 표현 하겠습니다🫡.
먼저 배틀그라운드의 자기장 시스템에 대해서 기억해두셔야 합니다.
첫 SafetyZone 영역이 지정되고 → 대기시간 → 첫 자기장 출발 → 자기장이 도착하면 두번째 SafetyZone 영역 정의 → 대기시간... → 두번째 자기장 출발 ... 의 반복이라고 생각하시면 됩니다.
즉, 핵심은 SafetyZone이 정의되고 블루존이 움직인다 그리고 블루존이 도착하면 '바로'
새로운 SafetyZone이 지정된다는것입니다. 그렇다면 위 시스템이 데이터에 어떻게 남겨져있는지 간단하게 보겠습니다!🫡
LogGameStatePeriodic
먼저, SafetyZone과 관련된 데이터는 로그 이벤트값이
LogGameStatePeriodic
인 경우에 남겨지고 있습니다.
샘플로 하나의 매치 자기장 데이터를 보겠습니다. (8x8 크기의 에란겔 경기입니다.)
먼저, LogGameStatePeriodic
일 때 남겨지는 핵심 컬럼은 gameState.safetyZonePosition.x
,~y
,~Radius
입니다.
각 컬럼들은 SafetyZone의 x,y좌표(중심축) 과 영역(원의 둘레)를 나타내는데요. _D
를 보면 알 수 있듯이 10초단위로 데이터가 남겨지고 있습니다.
SafetyZone이 정의되고나면 바로 자기장이 다가오지 않고 몇 초동안 유저가 SafetyZone안에 들어갈 시간을 줍니다. (페이즈가 높아질 수록 점점 촉박해지구요)
그렇다면 첫 번째 SafetyZone은 (408000 , 408000) 그리고 581999.125 둘레에 해당하는 영역입니다. 초록색에 해당하는 부분이죠. 그런데 해당부분을 시각화 하게 되면 맵의 중심 전체영역에 해당하는데요. 유저가 낙하하기 전 사실상 0페이즈, 초기값이라고 보시면 됩니다.
여러 데이터를 확인해보시면 8x8 크기에 해당하는 맵들의 초기값 SafetyZone은 모두 동일한것을 알 수 있습니다.
그리고 게임 시작 120초 후(8x8맵 기준)에 첫 번째 SafetyZone이 정의되고 일정시간이 지난뒤 자기장이 다가오기 때문에 점점 줄어드는 SafetyZone값을 보실 수 있습니다.
해석해보면 빨간색 영역이 첫번째 SafetyZone 이고 파란색 영역은 자기장이 다가오는 과정이라고 볼 수 있습니다. 첫번째 영역 밖까지 자기장이 오고있는것이죠 (=SafetyZone이 줄어드는)
왜 빨간색이 첫번째 SafetyZone이냐구요? 아까 자기장 규칙에 대해서 말했듯이 SafetyZone이 정의되어야 블루존이 오기 때문입니다. 그리고 블루존이 빨간색 영역까지 도달했을 때, 이미 다음 SafetyZone이 지정된 상태 입니다. 그리고 유저가 이동할 자기장 대기시간을 줍니다.
(게임을 안해보셨다면 좀 헷갈릴 수 있습니다😂 아래에서 계속 설명하니 일단 이해해봅시다!)
그렇다면 이 몇 초동안 고정되어있는 좌표가 페이즈별 SafetyZone 영역이라는 것을 알 수 있습니다. 이 규칙을 이용해서 저는 페이즈별 SafetyZone 테이블을 전처리했습니다.
df['_D'] = pd.to_datetime(df['_D'])
zone_df = df.groupby(['matchId','gameState.safetyZonePosition.x','gameState.safetyZonePosition.y','gameState.safetyZoneRadius']).agg(
_D = pd.NamedAgg(column='_D',aggfunc = 'min'), # 다음 자기장이 정해지는 시간
count = pd.NamedAgg(column='gameState.safetyZoneRadius',aggfunc='count'), # 페이즈별 SafetyZone 좌표를 알 수 있는 key
).reset_index().sort_values(by=['matchId','gameState.safetyZoneRadius'],ascending=False)
matchId
, x
, y
, radius
를 groupby해주어서 2번 이상 고정되어있는 영역을 구해주었습니다. Radius
를 기준으로 같은 영역이 20초 이상 지속된 경우 페이즈별 SafetyZone 이라고 본 것입니다. ( 10초 단위로 남겨지고 있기 때문에 count값이 2 인경우 20초 이상 지속되었다고 볼 수 있습니다. )(대기시간 이외에) 자기장이 일단 출발하게되면 갑자기 도중에 멈추는 경우는 절대로 없기에 count값이 2이상인 경우 페이즈별 SafetyZone 이라고 볼 수 있습니다.
최종적으로 count가 2 이상인 행들만 가져오면 아래처럼 각 경기의 페이즈별 SafetyZone좌표 테이블을 구할 수 있는데요!
페이즈 단계가 높아질수록 점점 count가 줄어드는것 보이시나요? (8x8 맵 기준 - 에란겔, 태이고 등)
8x8 맵의 어떤 경기를 집계해도 위 와같이 동일한 count가 집계되는데요! 페이즈별 SafetyZone이 정의되는 시간은 게임 시스템에서 동일하게 정해져있기 때문입니다. (물론 커스텀 경기인 normal 모드의 경우, 방장이 자기장 시간을 조절할 수 있으므로 예외😂)
페이즈별 SafetyZone 좌표는 구했으나 만약에 해당 영역이 정의되는 시간도 추가하기 위해서는 약간의 전처리가 더 필요합니다.
다시 SafetyZone 세부 로그를 보겠습니다!
위 로그의 경우, 자기장이 첫번째 SafetyZone(빨간색)까지 이미 도착한 상태이며 도착했을 때는 동시에 두번째 SafetyZone이 정의되고 다음 자기장을 대기하고 있는 상태입니다.
즉, 빨간영역의 최소
_D
값이 두번째 SafetyZone이 정의되는 시간인 것입니다.
이해를 위해 그림으로 표현하면 아래와 같습니다. 자기장이 ①SafetyZone까지 도착하면 바로 ②SafetyZone이 정의됩니다. 그리고 다시 대기시간을 주는것이죠.
그래서 각 페이즈별 _D
최소값이 필요했으며, 다음행으로 땡겨오는 작업만 하면 되는것 입니다. (그럼 0페이즈는요? 어차피 초기값이라 필요없습니다!)
SQL의 LAG 함수를 이용한다고 보시면됩니다😀!
zone_df['_D'] = zone_df.groupby('matchId')['_D'].shift(1)
zone_df.loc[zone_df['page'] == 1, '_D'] = zone_df.apply(
lambda row: row['_D'] + timedelta(seconds=90) if row['MapName'] == 'Savage_Main' else row['_D'] + timedelta(seconds=120),
axis=1
)
zone_df.loc[zone_df['page'] == 1, '_D'] = zone_df.loc[zone_df['page'] == 1, '_D'] + timedelta(seconds=120)
하지만 첫번째 SafetyZone 정의되는 시간에는 예외가 있습니다!
위 페이즈별 SafetyZone이 정의되는 시간에 따르면
위 테이블을 보면 알 수 있듯이 최소시간을 땡겨오기 때문에 page 1 인 경우는 완전 초기값(page 0)을 땡겨오게 됩니다. 게임이 시작되자마자 첫번째 SafetyZone이 나오지 않기때문에 위 규칙에따라 page1인 경우에만 120초를 더해주기만 하면 됩니다!timedelta(seconds=120)
(+ 사녹맵의 경우 90초를 더해주면 되겠죠!)
그렇다면, 최종적으로 초기값(0페이즈)을 제외한 1페이즈 ~ 경기종료 페이즈까지의 SafetyZone 테이블이 완성됩니다.
사실 제 방법이 정답은 아닙니다! 테이블을 구하는 방법에는 여러 방법이있겠습니다ㅎㅎ 저는 최대한 해당 데이터만 이용해서 구해보았습니다.
물론 아래의 맵별 자기장 시간을 이용해 시간규칙을 애초에 정의하여 테이블을 전처리 할 수 있구요! 방법은 많겠습니다!
정리를 해보았는데 설명하는게 참 어려운것 같습니다 ㅎㅎ.. 모쪼록 자기장 데이터 부분에 대해 궁금한 분이 있다면 해당글이 도움이 되었으면 좋겠습니다.
# SafetyZone에 관한 데이터만 가져옵니다.
zone_df = df[(df['_T'] == 'LogGameStatePeriodic')].sort_values(by=['_D'])[['_T','_D','matchId','MapName','gameState.safetyZonePosition.x','gameState.safetyZonePosition.y' ,'gameState.safetyZoneRadius']]
# 게임 규칙을 이용해 테이블을 집계합니다.
zone_df['_D'] = pd.to_datetime(zone_df['_D'])
zone_df = zone_df.groupby(['matchId','MapName','gameState.safetyZonePosition.x','gameState.safetyZonePosition.y','gameState.safetyZoneRadius']).agg(
_D = pd.NamedAgg(column='_D',aggfunc = 'min'), # 다음 자기장이 정해지는 시간
count = pd.NamedAgg(column='gameState.safetyZoneRadius',aggfunc='count')
).reset_index().sort_values(by=['matchId','gameState.safetyZoneRadius'],ascending=False)
zone_df = zone_df[zone_df['count'] >= 2] # count가 2이상인 경우 = SafetyZone 영역에 해당함 = 자기장이 SafetyZone까지 도착하고 대기했다는 뜻
zone_df['page'] = zone_df.groupby('matchId')['gameState.safetyZoneRadius'].rank(method='dense', ascending=False).astype('int')-1 # 페이즈 순서 (0페이즈 초기값 포함)
zone_df['_D'] = zone_df.groupby('matchId')['_D'].shift(1)
zone_df.loc[zone_df['page'] == 1, '_D'] = zone_df.apply(
lambda row: row['_D'] + timedelta(seconds=90) if row['MapName'] == 'Savage_Main' else row['_D'] + timedelta(seconds=120),
axis=1
)
빅쿼리에 데이터를 옮겨와서 sql 언어로도 전처리 해보았습니다! colab에서 컬럼명이 gameState.SafetyZonePosition 이런식으로 '.' 으로 연결되어 있었는데, 빅쿼리에서는 컬럼명에 온점 사용이 불가능합니다;😂. 그래서 언더바'_'로 수정이 필요합니다.
LAG()
, COUNT()
, ROW_NUMBER()
, CASE WHEN
부분이 되겠습니다.LAG()
: 경기, 페이즈별 SafetyZone 정의되는 시간(MIN(_D)
)을 땡겨오는 부분COUNT()
: 경기, 페이즈별 SafetyZone 좌표를 구하기위한 부분ROW_NUMBER()
: 경기별 페이즈CASE WHEN
: 페이즈가 1인 경우 + 맵에 따라 SafetyZone이 정의되는 시간 추가 계산WITH tbl as (
SELECT matchId
, MapName
, LAG(MIN(_D),1) OVER (PARTITION BY matchId ORDER BY gameState_safetyZoneRadius DESC) AS LAG_D
, gameState_safetyZonePosition_x as safetyZone_x
, gameState_safetyZonePosition_y as safetyZone_y
, gameState_safetyZoneRadius as safetyZone_radius
, COUNT(gameState_safetyZoneRadius) as count
, ROW_NUMBER() OVER (PARTITION BY matchId ORDER BY matchId ASC) - 1 as phase
FROM PUBG.bluezone
WHERE _T ='LogGameStatePeriodic'
GROUP BY matchId, MapName, gameState_safetyZonePosition_x, gameState_safetyZonePosition_y, gameState_safetyZoneRadius
HAVING count > 1
)
SELECT matchId
, MapName
, CASE WHEN MapName = 'Savage_Main' and phase = 1 THEN datetime_add(LAG_D, interval 90 second)
WHEN MapName != 'Savage_Main' and phase = 1 THEN datetime_add(LAG_D, interval 120 second) ELSE LAG_D END AS _D
, safetyZone_x
, safetyZone_y
, safetyZone_radius
, phase
FROM tbl
WHERE phase > 0
진짜 너무 감사합니다 ,, 정독해보았는데 큰 도움 될 것 같습니다.
참고해서 꼭 성공해보겠습니다 ! 좋은 밤 되세요 :3