refactor: 音频系统重写 + TTS重新生成全部音频
- sound.js: Web Audio API重写,独立播放链,优先级系统,AudioBuffer缓存 - sound.config.js: 6并发+重试+渐进加载,替代Promise.all一挂全挂 - play.vue: 手动模式跳过点数播报,直接报结果 - 全部音频用TTS重新生成,修复原始文件内容与文件名不匹配的问题
This commit is contained in:
+330
-85
@@ -1,100 +1,345 @@
|
||||
/* eslint-disable */
|
||||
import $store from "@/store"
|
||||
const volume = $store.state.config.volume
|
||||
let Mp3List = [],
|
||||
num = 0
|
||||
|
||||
function getBlob(key) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Priority levels: higher number = higher priority = cannot be interrupted
|
||||
// ---------------------------------------------------------------------------
|
||||
const PRIORITY = {
|
||||
UI: 1, // chip clicks, push, check
|
||||
COUNTDOWN: 2, // time, last_10_seconds
|
||||
RESULT: 3 // game result announcement chains
|
||||
}
|
||||
|
||||
// Keywords that determine priority tier for a given sound key list
|
||||
const RESULT_KEYS = [
|
||||
"baccarat_b_win", "baccarat_p_win", "baccarat_tie",
|
||||
"baccarat_b_pair", "baccarat_p_pair", "baccarat_banker", "baccarat_player",
|
||||
"lh_dragon_win", "lh_tiger_win", "lh_tie", "lh_dragon", "lh_tiger",
|
||||
"nn_banker", "nn_player_1", "nn_player_2", "nn_player_3",
|
||||
"toning_4_white", "toning_4_red", "toning_3_w_1_r", "toning_2_w_2_r",
|
||||
"toning_1_w_3_r", "toning_even", "toning_odd", "toning_big", "toning_small",
|
||||
"dice_any_triple",
|
||||
"start_betting", "stop_betting",
|
||||
"tc_banker_stop", "tc_start_banker", "tc_banker_success"
|
||||
]
|
||||
const COUNTDOWN_KEYS = ["time", "last_10_seconds"]
|
||||
|
||||
function inferPriority(keyList) {
|
||||
if (!keyList || !keyList.length) return PRIORITY.UI
|
||||
for (const key of keyList) {
|
||||
if (RESULT_KEYS.some(rk => key.includes(rk))) return PRIORITY.RESULT
|
||||
// _point keys are part of result chains (e.g. "3_point")
|
||||
if (/_point$/.test(key)) return PRIORITY.RESULT
|
||||
// w_p keys are welcome phrases (result-tier)
|
||||
if (/_w_p\d/.test(key)) return PRIORITY.RESULT
|
||||
// nn/tc bull results
|
||||
if (/_(bull_|no_bull|five_pictur|any_triple|straight_flush|royal_flush)/.test(key)) return PRIORITY.RESULT
|
||||
if (/dice_num_/.test(key)) return PRIORITY.RESULT
|
||||
}
|
||||
for (const key of keyList) {
|
||||
if (COUNTDOWN_KEYS.includes(key)) return PRIORITY.COUNTDOWN
|
||||
}
|
||||
return PRIORITY.UI
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AudioBuffer cache: decode blob -> AudioBuffer once, reuse forever
|
||||
// ---------------------------------------------------------------------------
|
||||
const bufferCache = new Map() // key -> AudioBuffer
|
||||
let audioCtx = null
|
||||
|
||||
function getAudioContext() {
|
||||
if (!audioCtx) {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext
|
||||
if (AudioContext) {
|
||||
audioCtx = new AudioContext()
|
||||
}
|
||||
}
|
||||
return audioCtx
|
||||
}
|
||||
|
||||
// Resume AudioContext after user gesture (mobile autoplay restriction)
|
||||
function ensureResumed() {
|
||||
const ctx = getAudioContext()
|
||||
if (ctx && ctx.state === "suspended") {
|
||||
ctx.resume().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// One-time listener to unlock AudioContext on first user interaction
|
||||
let unlockBound = false
|
||||
function bindUnlock() {
|
||||
if (unlockBound) return
|
||||
unlockBound = true
|
||||
const unlock = () => {
|
||||
ensureResumed()
|
||||
document.removeEventListener("touchstart", unlock, true)
|
||||
document.removeEventListener("touchend", unlock, true)
|
||||
document.removeEventListener("click", unlock, true)
|
||||
}
|
||||
document.addEventListener("touchstart", unlock, true)
|
||||
document.addEventListener("touchend", unlock, true)
|
||||
document.addEventListener("click", unlock, true)
|
||||
}
|
||||
bindUnlock()
|
||||
|
||||
/**
|
||||
* Decode a Blob into an AudioBuffer and cache it.
|
||||
* Returns null if the key doesn't exist or decoding fails.
|
||||
*/
|
||||
async function getBuffer(key) {
|
||||
if (bufferCache.has(key)) return bufferCache.get(key)
|
||||
|
||||
const ctx = getAudioContext()
|
||||
if (!ctx) return null
|
||||
|
||||
const soundList = $store.state.config.soundList
|
||||
const language =
|
||||
$store.state.config.$Type == "cn" || $store.state.config.$Type == "tw"
|
||||
$store.state.config.$Type === "cn" || $store.state.config.$Type === "tw"
|
||||
? "cn"
|
||||
: "en"
|
||||
let blob = ""
|
||||
const fullKey = `${language}_${key}`
|
||||
const blob = soundList[fullKey]
|
||||
if (!blob) return null
|
||||
|
||||
try {
|
||||
blob = soundList[`${language}_${key}`]
|
||||
blob = window.webkitURL.createObjectURL(blob) || URL.createObjectURL(blob)
|
||||
} catch (err) {
|
||||
console.warn(err, key)
|
||||
}
|
||||
return blob
|
||||
}
|
||||
|
||||
let audio = null
|
||||
const audioElement = document.getElementById("Audio")
|
||||
const sourceElement = document.getElementById("audioSourceAside")
|
||||
audio = audioElement
|
||||
audio.addEventListener("ended", () => {
|
||||
nextAudio(audio, sourceElement)
|
||||
})
|
||||
|
||||
function audioMp3(mp3List) {
|
||||
const effectsVolume = $store.state.config.effectsVolume
|
||||
// const audioElement = document.createElement("audio")
|
||||
// const sourceElement = document.createElement("source")
|
||||
// audio.appendChild(sourceElement)
|
||||
// audio.volume = volume.effects / 100
|
||||
audio.muted = !effectsVolume
|
||||
|
||||
Mp3List = mp3List
|
||||
var mp3 = new Object()
|
||||
mp3.mp3List = mp3List
|
||||
mp3.auto_play = false
|
||||
mp3.loop = false
|
||||
mp3.Play = function () {
|
||||
num = 0
|
||||
if (this.mp3List[0]) {
|
||||
sourceElement.src = getBlob(this.mp3List[0])
|
||||
sourceElement.type = "audio/mpeg"
|
||||
audio.pause()
|
||||
audio.load()
|
||||
setTimeout(() => {
|
||||
audio.play().catch((err) => {
|
||||
console.log(err)
|
||||
}, 200)
|
||||
// Blob.arrayBuffer() may not exist on older mobile browsers; fallback to FileReader
|
||||
let arrayBuffer
|
||||
if (typeof blob.arrayBuffer === "function") {
|
||||
arrayBuffer = await blob.arrayBuffer()
|
||||
} else {
|
||||
arrayBuffer = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onerror = reject
|
||||
reader.readAsArrayBuffer(blob)
|
||||
})
|
||||
}
|
||||
}
|
||||
mp3.Pause = function () {
|
||||
audio.pause()
|
||||
}
|
||||
mp3.Muted = function () {
|
||||
audio.muted ? (audio.muted = false) : (audio.muted = true)
|
||||
}
|
||||
mp3.volumeAdd = function () {
|
||||
if (audio.volume.toFixed(1) >= 1) {
|
||||
audio.volume = 1
|
||||
} else {
|
||||
audio.volume = audio.volume + 0.1
|
||||
}
|
||||
}
|
||||
mp3.volumeMinus = function () {
|
||||
if (audio.volume.toFixed(1) <= 0) {
|
||||
audio.volume = 0
|
||||
} else {
|
||||
audio.volume = audio.volume - 0.1
|
||||
}
|
||||
}
|
||||
return mp3
|
||||
}
|
||||
function nextAudio(audio, sourceElement) {
|
||||
num += 1
|
||||
// console.log(Mp3List, num)
|
||||
if (Mp3List && num < Mp3List.length) {
|
||||
sourceElement.src = getBlob(Mp3List[num])
|
||||
sourceElement.type = "audio/mpeg"
|
||||
audio.load()
|
||||
setTimeout(() => {
|
||||
audio.play().catch((err) => {
|
||||
console.log(err)
|
||||
}, 500)
|
||||
// decodeAudioData: some older browsers only support callback form
|
||||
const audioBuffer = await new Promise((resolve, reject) => {
|
||||
ctx.decodeAudioData(arrayBuffer, resolve, reject)
|
||||
})
|
||||
} else {
|
||||
audio.pause()
|
||||
audio.currentTime = 0.0
|
||||
num = 0
|
||||
audio.removeEventListener("ended", () => {})
|
||||
bufferCache.set(key, audioBuffer)
|
||||
return audioBuffer
|
||||
} catch (err) {
|
||||
console.warn("[sound] decode failed:", key, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback: HTMLAudio pool for browsers where Web Audio API is unavailable
|
||||
// ---------------------------------------------------------------------------
|
||||
const HTML_POOL_SIZE = 4
|
||||
const htmlPool = []
|
||||
|
||||
function getHtmlAudio() {
|
||||
// Try to reuse an idle element
|
||||
for (const el of htmlPool) {
|
||||
if (el.paused || el.ended) return el
|
||||
}
|
||||
// Create new if pool not full
|
||||
if (htmlPool.length < HTML_POOL_SIZE) {
|
||||
const el = new Audio()
|
||||
el.preload = "auto"
|
||||
htmlPool.push(el)
|
||||
return el
|
||||
}
|
||||
// All busy: steal the oldest
|
||||
return htmlPool[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a single sound key using HTMLAudioElement (fallback path).
|
||||
* Creates and revokes blob URL to prevent memory leaks.
|
||||
*/
|
||||
function playHtmlFallback(key) {
|
||||
return new Promise((resolve) => {
|
||||
const soundList = $store.state.config.soundList
|
||||
const language =
|
||||
$store.state.config.$Type === "cn" || $store.state.config.$Type === "tw"
|
||||
? "cn"
|
||||
: "en"
|
||||
const fullKey = `${language}_${key}`
|
||||
const blob = soundList[fullKey]
|
||||
if (!blob) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const el = getHtmlAudio()
|
||||
const effectsVolume = $store.state.config.effectsVolume
|
||||
el.muted = !effectsVolume
|
||||
|
||||
const cleanup = () => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
el.removeEventListener("ended", onEnd)
|
||||
el.removeEventListener("error", onErr)
|
||||
}
|
||||
const onEnd = () => { cleanup(); resolve() }
|
||||
const onErr = () => { cleanup(); resolve() }
|
||||
|
||||
el.addEventListener("ended", onEnd, { once: true })
|
||||
el.addEventListener("error", onErr, { once: true })
|
||||
el.src = blobUrl
|
||||
el.load()
|
||||
el.play().catch(() => { cleanup(); resolve() })
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active chain tracking for priority-based interruption
|
||||
// ---------------------------------------------------------------------------
|
||||
let activeChainId = 0
|
||||
let activeChainPriority = 0
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: audioMp3(mp3List) => { Play(), Pause() }
|
||||
// ---------------------------------------------------------------------------
|
||||
function audioMp3(mp3List) {
|
||||
// Handle no-args call: audioMp3().Pause() for global stop
|
||||
if (!mp3List || !mp3List.length) {
|
||||
return {
|
||||
Play() {},
|
||||
Pause() { stopAll() }
|
||||
}
|
||||
}
|
||||
|
||||
const myList = [...mp3List] // snapshot, no shared mutable state
|
||||
const myPriority = inferPriority(myList)
|
||||
let cancelled = false
|
||||
let myId = 0
|
||||
let currentSource = null // Web Audio source node, if playing
|
||||
|
||||
const chain = {
|
||||
Play() {
|
||||
ensureResumed()
|
||||
|
||||
// Priority check: if something equal or higher priority is playing, and
|
||||
// we are UI-tier, skip (don't interrupt results with clicks).
|
||||
// If we are higher or equal priority, we take over.
|
||||
if (activeChainPriority > myPriority) {
|
||||
// Higher priority chain already playing -- don't interrupt
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique chain ID; any older chain with lower/equal priority
|
||||
// is implicitly superseded
|
||||
myId = ++activeChainId
|
||||
activeChainPriority = myPriority
|
||||
cancelled = false
|
||||
|
||||
playSequence(myList, 0)
|
||||
},
|
||||
Pause() {
|
||||
cancelled = true
|
||||
if (currentSource) {
|
||||
try { currentSource.stop() } catch (_) {}
|
||||
currentSource = null
|
||||
}
|
||||
// If this chain is the active one, clear active state
|
||||
if (myId === activeChainId) {
|
||||
activeChainPriority = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively play sounds in sequence via Web Audio API.
|
||||
* Falls back to HTMLAudio if Web Audio is unavailable.
|
||||
*/
|
||||
async function playSequence(list, idx) {
|
||||
if (cancelled) return
|
||||
if (myId !== activeChainId) return // superseded by a newer chain
|
||||
if (idx >= list.length) {
|
||||
// Chain finished
|
||||
if (myId === activeChainId) {
|
||||
activeChainPriority = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const key = list[idx]
|
||||
const ctx = getAudioContext()
|
||||
|
||||
if (ctx) {
|
||||
// --- Web Audio path ---
|
||||
const buffer = await getBuffer(key)
|
||||
if (cancelled || myId !== activeChainId) return
|
||||
|
||||
if (!buffer) {
|
||||
// Key missing: skip and continue chain
|
||||
playSequence(list, idx + 1)
|
||||
return
|
||||
}
|
||||
|
||||
const effectsVolume = $store.state.config.effectsVolume
|
||||
const source = ctx.createBufferSource()
|
||||
source.buffer = buffer
|
||||
|
||||
// Gain node for mute control
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.value = effectsVolume ? 1 : 0
|
||||
source.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
|
||||
currentSource = source
|
||||
|
||||
source.onended = () => {
|
||||
currentSource = null
|
||||
if (!cancelled && myId === activeChainId) {
|
||||
playSequence(list, idx + 1)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
source.start(0)
|
||||
} catch (err) {
|
||||
console.warn("[sound] play error:", key, err)
|
||||
currentSource = null
|
||||
// Skip this sound and continue
|
||||
playSequence(list, idx + 1)
|
||||
}
|
||||
} else {
|
||||
// --- HTMLAudio fallback path ---
|
||||
await playHtmlFallback(key)
|
||||
if (!cancelled && myId === activeChainId) {
|
||||
playSequence(list, idx + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all playback globally. Used for cleanup on unmount.
|
||||
*/
|
||||
function stopAll() {
|
||||
activeChainId++
|
||||
activeChainPriority = 0
|
||||
// Stop any Web Audio sources
|
||||
const ctx = getAudioContext()
|
||||
if (ctx && ctx.state === "running") {
|
||||
// Suspending and resuming is the cleanest way to halt all sources
|
||||
ctx.suspend().then(() => ctx.resume()).catch(() => {})
|
||||
}
|
||||
// Stop HTML pool elements
|
||||
for (const el of htmlPool) {
|
||||
try {
|
||||
el.pause()
|
||||
el.currentTime = 0
|
||||
} catch (_) {}
|
||||
}
|
||||
// Also pause the legacy <audio> element in case it was somehow used
|
||||
try {
|
||||
const legacyEl = document.getElementById("Audio")
|
||||
if (legacyEl) {
|
||||
legacyEl.pause()
|
||||
legacyEl.currentTime = 0
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export { audioMp3 }
|
||||
|
||||
+77
-48
@@ -1,12 +1,5 @@
|
||||
// 处理文件数据
|
||||
// let loadList = []
|
||||
// const modulesFiles = require.context("./", true, /\.mp3$/)
|
||||
// modulesFiles.keys().forEach((v) => {
|
||||
// const path = v.replace(/^\.\/(.*)/, "$1")
|
||||
// const name = path.replace(/\.\w+$/, "").replaceAll(/\//gi, "_")
|
||||
// loadList = [...loadList, { [name]: path }]
|
||||
// })
|
||||
import store from "@/store"
|
||||
|
||||
const sound = [
|
||||
"cn/0_point.mp3",
|
||||
"cn/1_point.mp3",
|
||||
@@ -348,47 +341,83 @@ const sound = [
|
||||
"en/lp/w_p6.mp3",
|
||||
"en/lp/w_p7.mp3"
|
||||
]
|
||||
// const dev = "./static/sound"
|
||||
// const proxy = "/v8api/sound"
|
||||
// const pro = "https://static.v8api.net"
|
||||
let times = null
|
||||
const promiseSoundUrl = () => {
|
||||
const soundUrl = store.state.config.soundUrl
|
||||
if (soundUrl) {
|
||||
clearTimeout(times)
|
||||
init(soundUrl)
|
||||
} else {
|
||||
times = setTimeout(() => {
|
||||
promiseSoundUrl()
|
||||
}, 1000)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency control: fetch with a limited pool to avoid hammering the server
|
||||
// ---------------------------------------------------------------------------
|
||||
const CONCURRENCY = 6
|
||||
const MAX_RETRIES = 2
|
||||
const RETRY_DELAY = 1500 // ms
|
||||
|
||||
/**
|
||||
* Fetch a single sound file as a Blob with retry logic.
|
||||
* Returns { name, blob } on success, null on permanent failure.
|
||||
*/
|
||||
async function fetchWithRetry(soundUrl, url, retries = 0) {
|
||||
try {
|
||||
const response = await fetch(`${soundUrl}/${url}`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const blob = await response.blob()
|
||||
const name = url.replace(/\.\w+$/, "").replaceAll(/\//gi, "_")
|
||||
return { name, blob }
|
||||
} catch (err) {
|
||||
if (retries < MAX_RETRIES) {
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAY * (retries + 1)))
|
||||
return fetchWithRetry(soundUrl, url, retries + 1)
|
||||
}
|
||||
console.warn(`[sound.config] Failed to load after ${MAX_RETRIES + 1} attempts: ${url}`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
promiseSoundUrl()
|
||||
|
||||
function init(soundUrl) {
|
||||
Promise.all(
|
||||
sound.map(
|
||||
(url) =>
|
||||
fetch(`${soundUrl}/${url}`).then(async (response) => {
|
||||
const name = url.replace(/\.\w+$/, "").replaceAll(/\//gi, "_")
|
||||
const path = await response.blob()
|
||||
// const path = response.url
|
||||
return { [name]: path }
|
||||
})
|
||||
// {
|
||||
// const name = url.replace(/\.\w+$/, "").replaceAll(/\//gi, "_")
|
||||
// const path = `${soundUrl}/${url}`
|
||||
// return { [name]: path }
|
||||
// }
|
||||
)
|
||||
)
|
||||
.then((blobs) => {
|
||||
let list = {}
|
||||
blobs.forEach((v) => {
|
||||
list = { ...list, ...v }
|
||||
})
|
||||
// console.log(list)
|
||||
store.commit("config/soundList", list)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
/**
|
||||
* Load all sounds with concurrency limit.
|
||||
* Commits results to Vuex store in the same format: { "cn_baccarat_b_win": Blob, ... }
|
||||
*/
|
||||
async function loadAll(soundUrl) {
|
||||
const list = {}
|
||||
const queue = [...sound]
|
||||
let loaded = 0
|
||||
|
||||
async function worker() {
|
||||
while (queue.length > 0) {
|
||||
const url = queue.shift()
|
||||
const result = await fetchWithRetry(soundUrl, url)
|
||||
if (result) {
|
||||
list[result.name] = result.blob
|
||||
}
|
||||
loaded++
|
||||
// Commit partial results every 50 files so sounds become available sooner
|
||||
if (loaded % 50 === 0) {
|
||||
store.commit("config/soundList", { ...list })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn workers up to concurrency limit
|
||||
const workers = []
|
||||
for (let i = 0; i < Math.min(CONCURRENCY, sound.length); i++) {
|
||||
workers.push(worker())
|
||||
}
|
||||
await Promise.all(workers)
|
||||
|
||||
// Final commit with all loaded sounds
|
||||
store.commit("config/soundList", list)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wait for soundUrl to be available in store, then start loading
|
||||
// ---------------------------------------------------------------------------
|
||||
let pollTimer = null
|
||||
|
||||
function promiseSoundUrl() {
|
||||
const soundUrl = store.state.config.soundUrl
|
||||
if (soundUrl) {
|
||||
clearTimeout(pollTimer)
|
||||
loadAll(soundUrl)
|
||||
} else {
|
||||
pollTimer = setTimeout(promiseSoundUrl, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
promiseSoundUrl()
|
||||
|
||||
+6
-8
@@ -558,10 +558,9 @@ export default {
|
||||
let mp3list = [],
|
||||
win = [],
|
||||
text = ""
|
||||
mp3list.push("baccarat_banker")
|
||||
if(data.round.banker != null) mp3list.push(`${data.round.banker}_point`)
|
||||
mp3list.push("baccarat_player")
|
||||
if(data.round.player != null) mp3list.push(`${data.round.player}_point`)
|
||||
if(data.round.banker != null && data.round.player != null) {
|
||||
mp3list.push("baccarat_banker", `${data.round.banker}_point`, "baccarat_player", `${data.round.player}_point`)
|
||||
}
|
||||
if (data.round.opening == 1) {
|
||||
text = Lang.value[Type.value].msg_banker_win
|
||||
win.push("banker")
|
||||
@@ -629,10 +628,9 @@ export default {
|
||||
let mp3list = [],
|
||||
win = [],
|
||||
text = ""
|
||||
mp3list.push("lh_dragon")
|
||||
if(data.round.banker != null) mp3list.push(`${data.round.banker}_point`)
|
||||
mp3list.push("lh_tiger")
|
||||
if(data.round.player != null) mp3list.push(`${data.round.player}_point`)
|
||||
if(data.round.banker != null && data.round.player != null) {
|
||||
mp3list.push("lh_dragon", `${data.round.banker}_point`, "lh_tiger", `${data.round.player}_point`)
|
||||
}
|
||||
if (data.round.opening == 1) {
|
||||
text = Lang.value[Type.value].msg_dragon_win
|
||||
win.push("banker")
|
||||
|
||||
Reference in New Issue
Block a user