跳到正文
cccskills
免费在 GitHub 公开

melonjs-audio

Manage sound in melonJS games: load audio, play effects and looping background tracks, control volume, and place sounds in the game world. It also covers generated tones and noise, whole-mix effects, and common causes of silent playback.

中文简介正在准备中。当前显示英语简介。

原文查看原文说明

Use this skill for sound and music in melonJS — loading audio, playing effects, background tracks, audio sprites, volume and muting, spatial audio, procedural tone/noise generation, and effects such as reverb or a compressor on the whole mix. Covers the mandatory audio.init format list, the directory-not-file src convention, playTrack versus play, the fixed listener and pixel-scale distances of positional sound, and browser autoplay unlocking. Triggers on: audio, sound, audio.init, audio.play, playTrack, stopTrack, audio.load, sfx, music, volume, mute, fade, rate, pitch, sprite audio, stereo, panner, position, listener, refDistance, reverb, echo, delay, compressor, distortion, filter, audio effect, audio.tone, audio.noise, getAudioContext, getMasterGain, hasAudio, hasFormat, autoplay, no sound, sound stopped working, context suspended, silent after idle, clips silent but tones play.

melonjs/melonJS星标 6,429(GitHub 6,429 + cccskills 0)

实测评分 未实测

查看安装方法

适用场景

  • Loading sound files and choosing supported audio formats
  • Playing sound effects and looping background music
  • Positioning sounds around a player or game object
  • Generating simple tones or noise without audio files
  • Troubleshooting autoplay and silent clips

包含的文件(1)

  • SKILL.md25.6 KB

SKILL.md(原文)

安装前可以查看交给 AI 代理的指令内容。

Audio

Two setup rules

import { audio, loader } from "melonjs";

audio.init("mp3,ogg");                      // 1. BEFORE preloading any audio

await loader.preload([
    { name: "cling", type: "audio", src: "data/sfx/" },   // 2. a PREFIX, not a file
]);
  1. audio.init(format) must run before any audio asset is preloaded, and lists the formats you actually ship. Preloading first throws target audio extension(s) should be set through me.audio.init() before calling the preloader. It returns false when no audio backend is available, so it is worth checking. The format list defaults to "mp3".
  2. src is a prefix, not a file. The loader appends the asset name plus each configured extension and uses the first that loads — so data/sfx/ + cling + mp3 → data/sfx/cling.mp3. Passing a full filename gives you a 404. A data:audio/... URL is the one exception: it is used verbatim, prefix and extension skipped.

audio.hasAudio() says whether the browser can play audio at all, and audio.hasFormat("ogg") whether it can play one codec — useful for choosing the init list or hiding a sound toggle on a device with no audio.

Order matters — melonJS tries the listed formats left to right, so put the preferred one first. Two formats ("webm,mp3" or "mp3,ogg") is the usual belt-and-braces for codec coverage. Accepted tokens: mp3, mpeg, opus, ogg, oga, wav, aac, caf, m4a, m4b, mp4, weba, webm, dolby, flac.

Effects versus music

audio.play("cling");                    // fire and forget
audio.play("cling", false, null, 0.5);  // loop, onend, volume
const id = audio.play("engine", true);  // looping; keep the id to stop it

audio.playTrack("theme");               // background music
audio.stopTrack();

Use playTrack for music, not play. playTrack(name, volume?) always loops, and it records the name as the current track — which is what app.pauseOnBlur / resumeOnFocus / stopOnBlur act on, via state.pause(true) → pauseTrack(). stopTrack(), pauseTrack(), resumeTrack() and getCurrentTrack() all read that same single slot, so there is only ever one current track name.

playTrack stops the track it replaces (20.9.0 and later; before that it only assigned the slot, and the previous bed kept looping underneath where stopTrack() could no longer reach it). So a scene just states its own music and nothing cancels anything:

class PlayScreen extends Stage {
    onResetEvent() { audio.playTrack("battle", 0.6); }
}

Calling it with the track already playing restarts it, the same as play() on any other clip. A scene that wants the bed to carry on across a screen change should not call it again, rather than rely on it doing nothing.

stopTrack() is for where nothing follows: a scene whose successor has no music of its own, or a game going quiet. state.change destroys the outgoing stage before it resets the incoming one, so a stopTrack() in onDestroyEvent and a playTrack() in the next onResetEvent are in that order and never cancel each other.

To end a track on an event rather than on a screen change, say a run ending while the player stays on the same screen, audio.fade(name, from, 0, ms) takes it down gently. A fade to zero is silence, not a stop, so the voice is still live until something stops it or replaces it.

Every per-clip function throws audio clip <name> does not exist if the clip was never loaded, and play additionally throws when a named sprite region is missing.

Audio sprites

One file carrying many effects, with named regions in milliseconds:

await loader.preload([{
    name: "sfx", type: "audio", src: "data/sfx/",
    sprite: { jump: [0, 450], hit: [2000, 250], music: [4000, 12000, true] },
}]);

audio.play("sfx", { sprite: "jump" });

A loop with no gap

An MP3 encoder pads the end of its file. Chrome and Firefox trim the padding when they decode it, Safari keeps it, so a looping MP3 has a short silence at every loop there (27 ms on a 12 s clip at 22 kHz, measured). Declare a region of the loop's exact length and loop on that: the voice never reaches the padding.

await loader.preload([{
    name: "ambience", type: "audio", src: "data/sfx/",
    sprite: { loop: [0, 12000] },
}]);
audio.playTrack("ambience", { sprite: "loop", volume: 0.6 });
// or, as a plain clip: audio.play("ambience", { sprite: "loop", loop: true })

Ogg Vorbis carries no such padding.

The optional third element marks a region as looping: it loops unless play() is told loop: false (before 20.11 it only looped when loop: true was passed as well). play()'s second argument takes either the original loop boolean or a PlayOptions object (sprite, loop, onend, volume, plus the spatial follow, at and stopWithTarget) — both forms work. Object fields win over the positional onend / volume arguments when both are supplied.

Clip state

audio.duration("theme");        // seconds — 0 while still loading
audio.duration("sfx", id);      // duration of the region THAT instance plays
audio.playing("theme");         // is anything playing?
audio.playing("theme", id);     // is that instance playing?
audio.state("theme");           // "unloaded" | "loading" | "loaded"

stop(), pause() and resume() take an optional instance id too; omit it and the whole group is affected. audio.stop() with no arguments at all stops every sound. audio.seek(name) reads the position, audio.seek(name, s, id?) writes it; audio.rate(name, r, id?) changes speed and pitch together (0.5..4.0, 1 is normal) and audio.fade(name, from, to, ms, id?) ramps the volume (0..1, over milliseconds).

Volume and muting

audio.setVolume(0.5);           // global, 0..1
audio.getVolume();              // read it back
audio.mute("cling");            // one clip  — mute(name, id?, shouldMute = true)
audio.unmute("cling");
audio.muteAll();                // everything (audio.disable() is an alias)
audio.unmuteAll();              // (audio.enable() is an alias)
audio.muted();                  // global mute state

audio.unload(name) / audio.unloadAll() free the decoded buffers; unload also clears the current-track pointer if it named that clip.

Losing focus mutes everything

When the window loses focus the engine mutes all audio, and unmutes it on focus. That covers sound effects, procedural tone / noise and the mixer effects, not just the music track that state.pause() pauses. Set pauseOnBlur: false in the application settings if a clip has to keep playing in the background.

Spatial audio

audio.position("engine", x, y, z);
audio.orientation("engine", x, y, z);
audio.stereo("engine", -1);     // -1 left … 1 right
audio.panner("engine", { coneInnerAngle: 90, /* … */ });

position(name, x, y = 0, z = -0.5, id?) and orientation(name, x, y, z, id?) both default id to "the whole group". Called with just the clip name, each of the four returns the current value — stereo gives 0 and position gives [0, 0, 0] before they have ever been set.

Let the engine place the sound: follow and at

The shortest path to positional audio is a play option. Both take world pixels, the same numbers as pos.x / pos.y, and the engine converts to audio space for you:

// tracks the renderable every frame, and stops when it is destroyed
audio.play("ufo", { follow: this, stopWithTarget: true });

// pinned to a fixed world point
audio.play("portal", { at: { x: 1200, y: 480 } });

follow and at are mutually exclusive: passing both throws, rather than quietly picking one. follow re-reads the target's absolute position each frame, so a renderable nested in a moving container is placed correctly. stopWithTarget is what you want for a sound that belongs to an entity: without it a looping clip on a destroyed renderable keeps playing from wherever it last was. audio.unfollow(id) detaches a sound and leaves it playing where it is.

Move the listener, don't do the subtraction yourself

audio.setListener(player);                  // tracked every frame
audio.listener(x, y, z);                    // or place it by hand
audio.listener();                           // → [x, y, z] in world pixels
audio.listenerOrientation(fx, fy, fz, ux, uy, uz);
audio.setListener(null);                    // stop tracking

With a listener in the world, source positions are absolute world coordinates, not offsets. The older idiom — passing source.pos.x - player.pos.x every frame with the ear stuck at the origin — still works, but only if you leave the listener at (0, 0, 0); mixing the two double-counts the player's position.

setListener places the ear at the target's absolute position after the world updates. A plain renderable contributes position only, so a side-scroller keeps the default "facing into the screen" and gets left-right panning plus distance falloff from the one call.

A Camera3d target also contributes its orientation, taken from its own basis, so turning the camera swings the stereo image with it. That is the whole of what a posed 3D scene needs:

audio.setListener(app.viewport);                 // in a 3D scene this is a Camera3d
audio.play("drone", { follow: saucer });         // scene coordinates, directly

Y is down. Every one of these calls takes and returns melonJS world coordinates, where y grows downward; the Y-up flip Web Audio wants happens inside the engine. Pass pos.y as it is.

The defaults are tuned for pixels

audio.setSpatialDefaults() / getSpatialDefaults() set the panner attributes a sound placed through follow or at starts from, and the shipped values assume a 2D game measured in pixels:

attributedefault
refDistance240
maxDistance10000
panningModel"equalpower"
distanceModel"inverse"

With those, a placed sound is at full volume out to 240 px and then halves each time the distance doubles: about half at 480, a quarter at 960, roughly an eighth at 2000. maxDistance is where it stops getting quieter.

They apply to placed sounds only. A plain audio.play() followed by a bare audio.position() keeps WebAudio's own defaults, where refDistance is 1 METRE and a source 100 px out is already near silent. That path is unchanged, which is what keeps old code working.

Widen or tighten the field once, globally:

audio.setSpatialDefaults({ refDistance: 400, maxDistance: 20000 });

audio.panner(name, attrs, id?) still overrides per clip or per voice, and it merges over the current values rather than replacing them, so writing one field no longer resets the rest. Called without an id it writes the group defaults, which is what a clip-wide tuning call looks like.

For a 3D scene where front-to-back matters, panningModel: "HRTF" is worth the cost; for a 2D one it mostly muddies the stereo image.

stereo and position: last writer wins

Both drive the same per-instance panner node, and the engine swaps the node to match whichever you called last: stereo() after position() gives you plain left-right panning, and position() after stereo() gives you 3D falloff. Neither silently does nothing any more, and the order you call them in no longer matters.

Still pick one per voice and stay with it. A 2D game usually wants stereo() alone; reach for position() (or follow / at) when distance falloff matters.

Spatial audio is WebAudio-only. On a clip loaded with stream: true (or html5: true, or preload: false) stereo / position / orientation return early and do nothing — which is exactly the case for the long music tracks people reach for first. follow and at are equally inert on a streamed clip.

Procedural sound

For beeps, blips and noise without shipping files. Both take one options object, and duration (in seconds) is required:

audio.tone({ freq: 440, duration: 0.2, gain: 0.3, pan: -0.5 });
audio.tone({ freq: [440, 660, 880], duration: 0.4 });   // layered partials
audio.noise({ duration: 0.5, filter: { type: "lowpass", frequency: 800 } });

tone also takes wave (default "sine"), attack (0.005) and pitchSlide (1). noise takes type ("white" | "pink" | "brown", default "white"), attack, pan, filter and filterSweep. gain defaults to 0.1 on both.

Sequencing: delay, not setTimeout

Both take delay — seconds before the sound starts, scheduled on the audio clock. That is what builds a multi-part sound: a stinger's second note, an explosion's double-tap.

audio.tone({ freq: 392, duration: 0.12 });
audio.tone({ freq: 587, duration: 0.18, delay: 0.11 });
audio.tone({ freq: [784, 1176], duration: 0.75, delay: 0.23 });

Do not reach for setTimeout here. A timer fires on the main thread, so a busy frame slips the note late and the sequence audibly loses time — the fault is easy to blame on the sound design rather than the scheduling. delay is sample-accurate because the whole envelope is scheduled up front.

Both are silent no-ops when there is no WebAudio context — they bail on getAudioContext() === null rather than throwing. Neither needs the clip to be loaded; neither goes through the file-playback path at all.

audio.getAudioContext() and audio.getMasterGain() are the escape hatches for custom Web Audio work; both return null when audio is unavailable, so guard. Connect a custom graph to the master gain rather than ctx.destination if you want it to respect setVolume / muteAll.

Effects on all game audio: reverb, echo, compressor

There is no built-in reverb, delay, distortion or compressor, and no per-clip filter on file playback. There does not need to be one for the common case: every buffered clip and every tone / noise passes through the master gain on its way to the speakers, so an effect inserted after it applies to the whole mix.

const ctx = audio.getAudioContext();   // creates the context if needed
const master = audio.getMasterGain();
if (ctx && master) {                   // null when audio is unavailable
    const comp = ctx.createDynamicsCompressor();
    master.disconnect();               // master -> speakers becomes
    master.connect(comp);              // master -> compressor -> speakers
    comp.connect(ctx.destination);
}

Any Web Audio node or chain works the same way: a ConvolverNode with an impulse response for reverb, a DelayNode with a feedback gain for echo, a BiquadFilterNode for an underwater low-pass, a WaveShaperNode for distortion. Do it once, at startup; the master gain lives as long as the context.

  • setVolume / muteAll still work, because they act on the master gain, which is now upstream of the effect.
  • Streamed clips are not affected. A stream: true (html5: true) clip plays through an <audio> element and never reaches the master gain — which is usually the music. Preload the music buffered if it must go through the effect.
  • It is all or nothing. The per-instance nodes are not public, so an effect on one clip only (muffling one sound behind a wall) is not possible through this route; noise has its own filter for generated sounds.
  • To remove it, reverse the wiring: master.disconnect(); master.connect(ctx.destination);.

Autoplay: sound is silent until the first gesture

Browsers create the audio context in the suspended state and keep it there until a user interaction. Nothing errors — audio simply does not play, which reads as a bug. melonJS installs one-shot touchstart / touchend / click / keydown unlock listeners on document, so the first real gesture anywhere unlocks it and fires the clip's on.unlock callback. Design for it: start music on the first click or key press, not on load. The usual way to guarantee one is a gate screen — a splash, a "press any key", or a health/photosensitivity notice — whose dismissal is the gesture, so the menu behind it can start its music unconditionally. Note the unlock listens for keydown too, so a keyboard-only player is covered.

The context can stop again, long after it was unlocked

Unlocking is only the first half. A context that has been running can be stopped later: melonJS suspends it itself after thirty idle seconds to save power, and browsers stop it for their own reasons, a backgrounded tab among them. Recovery is automatic and you do not have to write it: a user gesture or the tab coming back to the front revives it, and a deliberate idle suspension is left alone so the power saving still works.

The symptom worth memorising, because it points straight at the cause: if tone and noise are audible while loaded clips are silent, the context is healthy and the engine's playback state is wrong. The generators only need a live context, where a clip goes through the engine's state machine and can be parked waiting for a resume. It is never "the context died".

Both routes ask the same owner to resume, so hearing a tone tells you the hardware is back; it tells you nothing about whether the clips waiting on the engine's state have been let go.

That asymmetry was a real Safari-only bug: thirty idle seconds before the first click left the engine waiting on a suspend the browser never confirmed, so every clip queued for the life of the page while generated sound played normally. Fixed in 20.9, and the lesson generalises: a clip that will not play is a state question, not a hardware one.

Playback also starts asynchronously: play() returns an id while the instance is still starting, so playing() can legitimately be false on the very next line.

Load-time options

{ name: "sfx", type: "audio", src: "data/sfx/",
  sprite: { jump: [0, 450] },
  pool: 5,            // finished instances kept for reuse — NOT a concurrency cap
  rate: 1.0,          // initial playback rate, 0.5..4.0
  mute: false,
  loop: false,
  autoplay: false,
  preload: true,      // false: fetched on first play, then streamed; or "metadata"
  format: "mp3",      // explicit hint when the URL has no usable extension
  stream: true,       // long music: stream rather than decode into memory
  on: { play() {}, end() {}, stop() {}, unlock() {} },
}

pool (default 5) is a reuse cap on finished voices, not a limit on simultaneous playback: when the number of ended voices exceeds it, the surplus are disconnected and dropped. Raise it for a clip triggered in rapid bursts, not to allow more overlap.

Use stream: true for long music tracks — it plays through an HTML5 <audio> element instead of decoding the whole file into memory. (html5: true is the same switch under its backend name.) on accepts the full lifecycle set: play, pause, stop, end, fade, seek, rate, volume, mute, unlock.

preload: false keeps a clip out of the preload. Nothing is downloaded for it until it is first played (audio.play, audio.playTrack, or autoplay). That play fetches it and streams it, as stream: true does, so the sound starts as soon as enough of the file has arrived, not once all of it has; a clip never played is never downloaded. Use it for music a player may never reach (a boss theme, the ending) to shorten the wait before the game starts.

  • The loader reports such a clip done at once: audio.state(name) is "unloaded" until its first play, then "loading", then "loaded".
  • It starts with the short delay of a stream on its first play. Keep a sound effect that must sound the instant it is triggered preloaded.
  • Being streamed, it has the limits of stream: true: positional sound does not apply to it, and it ignores withCredentials (see below).
  • Before 20.12.0 such a clip was never fetched at all, and stayed silent.

stream: true alone still fetches the clip during the preload; combine it with preload: false to defer that too. preload: "metadata" only matters for a streamed clip: the preload fetches just enough to know its duration.

Outside a preload, audio.load(asset, onload?, onerror?) takes the same descriptor and loads one clip at runtime — for a level's sounds fetched when the level starts. The loader calls the same function, so the same rules apply.

Re-preloading a manifest that already contains a loaded clip is a no-op — audio.unload(name) first if you genuinely want to reload it.

Credentials use the same global switch as every other asset type — loader.setOptions({ withCredentials: true }), not an audio-specific option. Buffered audio fetches through the shared loader path, so it honours it exactly as an image or a JSON file does. A streamed clip (stream: true, or preload: false) does not: it plays through an <audio> element, which needs a crossorigin attribute rather than fetch credentials. Preload a clip buffered if it sits behind a login.

Failing clips and setStopOnAudioError(false). The default is to fail the preload when a clip cannot be loaded after its retries: the error reaches the loader, so await loader.preload(...) rejects and your catch runs, and the reason is logged. Turn the flag off and the engine mutes audio, warns (failed loading <name>, disabling audio) and lets the load continue — the preload finishes and the game reaches the next scene without sound.

Before 20.4 both halves were broken: with the flag off it disabled audio and failed the preload anyway, and a clip whose bytes arrived but would not decode (a missing file served as HTML by an SPA rewrite, say) reported nothing at all either way — blank screen, empty console.

Symptom → cause

symptomcause
audio assets 404src given as a full filename instead of a directory prefix
target audio extension(s) should be set... thrown at preloadaudio.init() not called before preloading
audio clip X does not existthe clip was never preloaded, or the name is mis-cased — every per-clip call throws this
nothing plays until the user clicksbrowser autoplay policy — expected, design around it
playing() is false right after play()playback starts asynchronously
music keeps playing after leaving a sceneused play instead of playTrack, or no stopTrack in teardown
two music tracks playing at once, and stopTrack() only silences onemelonJS before 20.9.0: playTrack did not stop the previous track, so the scene that started it had to stopTrack() on the way out
the music restarts when returning to a screenplayTrack with the track already playing restarts it; don't call it again if the bed should carry on
stereo / position do nothingthe clip was loaded with stream: true or preload: false — spatial is WebAudio-only
a preload: false clip never plays, and audio.state() stays "unloaded"melonJS before 20.12.0 never fetched such a clip; preload it, or update
one format works, another does notthat codec is not in the audio.init() list
audio behind a login fails, or the preload hangsloader.setOptions({ withCredentials: true }) — the same switch every asset type uses
a streamed clip ignores withCredentialsstream: true plays through an <audio> element; preload it buffered instead
audio.tone(440, {...}) does nothing / errorstone takes a single options object with a required duration
tone / noise are silent with no errorno WebAudio context — getAudioContext() returned null
a positioned sound is silent, or only audible right next to the playerplaced by hand with audio.position(), which keeps WebAudio's metre defaults (refDistance: 1) — use follow / at, or set refDistance in pixels with audio.panner()
a positioned sound does not follow the playernothing is placing it — audio.play(name, { follow: entity }), and audio.setListener(player) once
a followed sound pans to the wrong side as the player movesthe listener was moved and positions are still relative to the player — with a listener in the world, pass absolute world coordinates
a looping sound outlives the entity that owns itfollow without stopWithTarget: true
a voice panned with stereo() loses its 3D placement, or the reverseone panner node per voice and last writer wins — pick one per voice and stay with it
a reverb / compressor on the master gain does not affect the musicthe music is stream: true, which bypasses the master gain — preload it buffered
a looping MP3 drops out for a moment at every loop, in Safari onlySafari keeps the encoder's padding at the end of the file: loop a sprite region of the clip's exact length (see "A loop with no gap")
sound effects keep playing after the window loses focuspauseOnBlur and stopOnBlur are both off, so the engine is deliberately leaving the game running in the background

Related skills

  • melonjs-getting-started — preloading and asset ordering

评价

还没有评价。欢迎分享你的使用感受。

评论

无需登录即可发表。欢迎分享使用感受、提问,或说明在哪些环境下无法运行。

还没有评论。来写第一条吧。

提交后会立即公开。请勿填写邮箱、电话等个人信息。URL 最多 2 个。

0 / 1000 字

此表单受 reCAPTCHA 保护,适用 Google 的隐私权政策和服务条款。

同一仓库的技能

melonjs

免费

Use this entry point to find the melonJS guide for a game development task, from application setup and scenes to sprites, input, physics, audio, and 3D. It also points to companion packages and the API index when the guides do not cover a need.

中文简介正在准备中。当前显示英语简介。

  • Choose a guide for a melonJS game task
  • Find the right setup steps for a new game
  • Locate guidance for a 3D scene or imported model
melonjs/melonJS64292026年10月29日 更新

Update older melonJS code for version 20.x. Review the asynchronous Application setup, renderer changes, post-effect API, and deprecated exports so you can identify outdated patterns when migrating or debugging expected APIs.

中文简介正在准备中。当前显示英语简介。

  • Migrating a game from melonJS 19.x
  • Checking why an older API is missing
  • Replacing deprecated shader assignments
melonjs/melonJS64292026年10月29日 更新

Build 3D or 2.5D scenes in melonJS with cameras, meshes, billboards, lights, shadows, and depth sorting. This guide explains the engine’s Y-down and +Z-forward coordinates, camera setup, transparency, and fog, and identifies features that require a GPU renderer instead of the Canvas fallback.

中文简介正在准备中。当前显示英语简介。

  • Setting up a Camera3d for a game stage
  • Placing meshes, billboards, or repeated instances
  • Fixing depth order or transparency in a 3D scene
melonjs/melonJS64292026年10月29日 更新

Load glTF, GLB, or OBJ/MTL assets into melonJS and understand how their materials, lights, animations, shadows, and instances are handled. The guide explains loader options and common format limitations so you can configure scenes and diagnose missing or incorrectly rendered asset features.

中文简介正在准备中。当前显示英语简介。

  • Loading a glTF or GLB scene into a melonJS level
  • Adding a static OBJ model with its MTL material
  • Checking why imported materials or lights look different
melonjs/melonJS64292026年10月29日 更新

Use this skill to control melonJS cameras and draw shapes, lines, and gradients. It covers target following, camera bounds and effects, coordinate conversion, clipping, rendering into a canvas target, and reading rendered pixels for images or data.

中文简介正在准备中。当前显示英语简介。

  • Making a camera follow a player within world bounds
  • Converting pointer or 3D positions to screen coordinates
  • Drawing shapes or gradients in a custom draw method
melonjs/melonJS64292026年10月29日 更新

Prepare a melonJS game for web builds, native app packaging, or mini-game platforms. The guide covers project scaffolding, browser syntax support, transpiling, asset-loading constraints, and practical bundle-size considerations.

中文简介正在准备中。当前显示英语简介。

  • Scaffolding a new melonJS project
  • Preparing a game for iOS or Android packaging
  • Checking support for older browsers
melonjs/melonJS64292026年10月29日 更新

查看 melonjs 的全部技能

报告此技能的问题