diff --git a/assets/sfx/weapons/guns/smg-hkmp5a3/1_unload.wav b/assets/sfx/weapons/guns/smg-hkmp5a3/1_unload_var0.wav
similarity index 100%
rename from assets/sfx/weapons/guns/smg-hkmp5a3/1_unload.wav
rename to assets/sfx/weapons/guns/smg-hkmp5a3/1_unload_var0.wav
diff --git a/assets/sfx/weapons/guns/smg-hkmp5k/1_unload.wav b/assets/sfx/weapons/guns/smg-hkmp5k/1_unload_var0.wav
similarity index 100%
rename from assets/sfx/weapons/guns/smg-hkmp5k/1_unload.wav
rename to assets/sfx/weapons/guns/smg-hkmp5k/1_unload_var0.wav
diff --git a/docs/weapons.md b/docs/weapons.md
index 80a772b..5850d14 100644
--- a/docs/weapons.md
+++ b/docs/weapons.md
@@ -3,7 +3,7 @@
| Combat knife | Combat knife | - | | - | - |
| Oriental sword | Katana | - | | - | - |
| Twin pistols | Beretta M9 | 9x19mm Parabellum | 15 | - | 381 |
-| Shotgun | Ithaca Model 37 | 12 gauge 2.75" shells | 8 | - | - |
+| Shotgun | Ithaca Model 37 | 12 gauge 2.75" shells | 8 | - | 410 |
| Sub-machine gun | H&K MP5A3 | 9x19mm Parabellum | 30 | 800 (cyclic) | 400 |
| Sub-machine gun | H&K MP5K | 9x19mm Parabellum | 30 | 900 (cyclic) | 375 |
| Sub-machine gun | H&K MP7 | HK 4.6x30mm | 30 | 950 (cyclic) | 735 |
diff --git a/tools/audio-interval-test/index.html b/tools/audio-interval-test/index.html
new file mode 100644
index 0000000..149e2ab
--- /dev/null
+++ b/tools/audio-interval-test/index.html
@@ -0,0 +1,60 @@
+
+
+
+
+ Audio Interval Test
+
+
+
+
+
+
+
diff --git a/tools/audio-interval-test/index.js b/tools/audio-interval-test/index.js
new file mode 100644
index 0000000..854fecf
--- /dev/null
+++ b/tools/audio-interval-test/index.js
@@ -0,0 +1,57 @@
+function AudioForm(el) {
+ const interval = el.elements['interval']
+ const play = el.elements['play']
+ const output = el.querySelector('output')
+ const audioPool = []
+ let intervalHandle = null
+ let audioIndex = 0
+ const items = 50
+
+ for (let i = 0; i < items; i += 1) {
+ const newAudio = new Audio('./sfx/weapons/guns/smg-hkmp7/1_unload_var0.wav')
+ newAudio.volume = 0.2;
+ newAudio.addEventListener('canplaythrough', () => {
+ audioPool[i].ready = true
+ })
+ newAudio.addEventListener('ended', () => {
+ newAudio.pause()
+ newAudio.currentTime = 0
+ })
+
+ audioPool[i] = {
+ audio: newAudio,
+ ended: true,
+ ready: false,
+ }
+ newAudio.load()
+ }
+
+ const playSound = () => {
+ for (let i = 0; i < items; i += 1) {
+ if (!audioPool[i].ready) {
+ return;
+ }
+ }
+
+ audioPool[audioIndex].audio.play();
+ audioIndex = (audioIndex + 1) % items;
+ }
+
+ el.addEventListener('submit', (e) => {
+ e.preventDefault()
+
+ const ms = 60000 / Number(interval.value);
+ output.innerText = `${ms} ms`
+ if (play.checked) {
+ intervalHandle = window.setInterval(() => {
+ playSound()
+ }, ms)
+ } else {
+ window.clearInterval(intervalHandle);
+ }
+
+ play.checked = !play.checked
+ })
+}
+
+const audioForm = new AudioForm(window.document.getElementById('formAudio'))
diff --git a/tools/audio-interval-test/sfx b/tools/audio-interval-test/sfx
new file mode 120000
index 0000000..43a23da
--- /dev/null
+++ b/tools/audio-interval-test/sfx
@@ -0,0 +1 @@
+../../assets/sfx
\ No newline at end of file
diff --git a/tools/websocket-client/index.html b/tools/websocket-client/index.html
new file mode 100644
index 0000000..5ff6c9f
--- /dev/null
+++ b/tools/websocket-client/index.html
@@ -0,0 +1,180 @@
+
+
+
+
+ Websocket Client
+
+
+
+ Websocket Client
+
+
+
+
+
+
diff --git a/tools/websocket-client/index.js b/tools/websocket-client/index.js
new file mode 100644
index 0000000..224c7a4
--- /dev/null
+++ b/tools/websocket-client/index.js
@@ -0,0 +1,149 @@
+const stringifyData = async value => {
+ const isString = typeof value === 'string'
+ const resolve = () => (
+ isString
+ ? Promise.resolve(value)
+ : value.arrayBuffer()
+ )
+
+ const a = await resolve()
+
+ if (isString) {
+ return `String:${a.length}(${a})`
+ }
+
+ const bytes = Array.from(new Uint8Array(a)).map(c => Number(c).toString(16).padStart(2, '0')).join(' ')
+ return `Binary:${a.byteLength}(${bytes})`
+}
+
+function ConnectionForm(el) {
+ let ws = null
+ el.addEventListener('submit', e => {
+ e.preventDefault()
+ if (!ws) {
+ const username = el.elements['username'].value
+ const baseUrl = el.elements['serverUrl'].value
+ ws = new WebSocket(`${baseUrl}?username=${username}`)
+
+ ws.addEventListener('open', () => {
+ this.messageForm.enable()
+ this.logsForm.append('Connection opened')
+ el.elements['serverUrl'].setAttribute('disabled', 'disabled')
+ el.elements['username'].setAttribute('disabled', 'disabled')
+ el.elements['connect'].innerText = 'Disconnect'
+ })
+
+ ws.addEventListener('message', evt => {
+ stringifyData(evt.data).then(value => {
+ this.logsForm.append(`Recv: ${value}`)
+ })
+ })
+
+ ws.addEventListener('error', evt => {
+ console.error(evt)
+ })
+
+ ws.addEventListener('close', event => {
+ this.messageForm.reset()
+ this.messageForm.disable()
+ this.logsForm.append(`Connection closed: wasClean: ${event.wasClean}, evCode: ${event.code}`)
+ el.elements['serverUrl'].removeAttribute('disabled')
+ el.elements['username'].removeAttribute('disabled')
+ el.elements['connect'].innerText = 'Connect'
+ ws = null
+ })
+ return
+ }
+ ws.close()
+ })
+
+ this.send = value => {
+ if (!ws) {
+ return
+ }
+ stringifyData(value).then(v => {
+ this.logsForm.append(`Send: ${v}`)
+ ws.send(value)
+ })
+ }
+}
+
+function MessageForm(el) {
+ const inputMessage = el.elements['message']
+ const binary = el.elements['binary']
+
+ inputMessage.addEventListener('keydown', e => {
+ if (e.key !== 'Shift') {
+ return;
+ }
+ binary.checked = true;
+ });
+
+ inputMessage.addEventListener('keyup', e => {
+ if (e.key !== 'Shift') {
+ return;
+ }
+ binary.checked = false;
+ });
+
+ el.addEventListener('submit', e => {
+ e.preventDefault()
+
+ if (inputMessage.value.length < 1) {
+ return
+ }
+
+
+ this.connectionForm.send(
+ binary.checked
+ ? new Blob(inputMessage.value.split(''))
+ : inputMessage.value,
+ );
+ inputMessage.value = ''
+ inputMessage.focus()
+ })
+
+ el.elements['submitBinary'].addEventListener('click', e => {
+ e.preventDefault()
+ if (inputMessage.value.length < 1) {
+ return
+ }
+ this.connectionForm.send(new Blob(inputMessage.value.split('')))
+ inputMessage.value = ''
+ inputMessage.focus()
+ })
+
+ this.reset = () => {
+ inputMessage.value = ''
+ }
+
+ this.enable = () => {
+ el.elements['fieldset'].removeAttribute('disabled')
+ }
+
+ this.disable = () => {
+ el.elements['fieldset'].setAttribute('disabled', 'disabled')
+ }
+}
+
+function LogsForm(el) {
+ let logs = el.elements['logs']
+
+ el.addEventListener('submit', e => {
+ e.preventDefault()
+ logs.value = ''
+ })
+
+ this.append = data => {
+ logs.value += data + '\n'
+ logs.scrollTop = logs.scrollHeight
+ }
+}
+
+const logsForm = new LogsForm(window.document.getElementById('formLogs'))
+const messageForm = new MessageForm(window.document.getElementById('formMessage'))
+const connectionForm = new ConnectionForm(window.document.getElementById('formConnection'))
+
+connectionForm.logsForm = logsForm
+connectionForm.messageForm = messageForm
+messageForm.connectionForm = connectionForm