mirror of
https://github.com/kichikuou/xsystem35-sdl2.git
synced 2026-10-03 20:27:58 +03:00
web/shell -> shell/
This commit is contained in:
+264
@@ -0,0 +1,264 @@
|
||||
/// <reference path="util.ts" />
|
||||
/// <reference path="volume.ts" />
|
||||
|
||||
namespace xsystem35 {
|
||||
declare var webkitAudioContext: any;
|
||||
export class AudioManager {
|
||||
private context: AudioContext;
|
||||
private masterGain: GainNode;
|
||||
private slots: PCMSound[];
|
||||
private buffers: AudioBuffer[];
|
||||
private isSafari: boolean;
|
||||
|
||||
constructor(volumeControl: VolumeControl) {
|
||||
if (typeof (AudioContext) !== 'undefined') {
|
||||
this.context = new AudioContext();
|
||||
} else if (typeof (webkitAudioContext) !== 'undefined') {
|
||||
this.context = new webkitAudioContext();
|
||||
this.isSafari = true;
|
||||
this.removeUserGestureRestriction();
|
||||
}
|
||||
this.masterGain = this.context.createGain();
|
||||
this.masterGain.connect(this.context.destination);
|
||||
this.slots = [];
|
||||
this.buffers = [];
|
||||
volumeControl.addEventListener(this.onVolumeChanged.bind(this));
|
||||
this.masterGain.gain.value = volumeControl.volume();
|
||||
}
|
||||
|
||||
private removeUserGestureRestriction() {
|
||||
let hanlder = () => {
|
||||
let src = this.context.createBufferSource();
|
||||
src.buffer = this.context.createBuffer(1, 1, 22050);
|
||||
src.connect(this.context.destination);
|
||||
src.start();
|
||||
console.log('AudioContext unlocked');
|
||||
window.removeEventListener('touchend', hanlder);
|
||||
};
|
||||
window.addEventListener('touchend', hanlder);
|
||||
}
|
||||
|
||||
private load(no: number): Promise<AudioBuffer> {
|
||||
let buf = this.getWave(no);
|
||||
if (!buf)
|
||||
return Promise.reject('Failed to open wave ' + no);
|
||||
|
||||
let decoded: Promise<AudioBuffer>;
|
||||
if (this.isSafari) {
|
||||
decoded = new Promise((resolve, reject) => {
|
||||
this.context.decodeAudioData(buf, resolve, reject);
|
||||
});
|
||||
} else {
|
||||
decoded = this.context.decodeAudioData(buf);
|
||||
}
|
||||
return decoded.then((audioBuf) => {
|
||||
this.buffers[no] = audioBuf;
|
||||
return audioBuf;
|
||||
});
|
||||
}
|
||||
|
||||
private getWave(no: number): ArrayBuffer {
|
||||
let dfile = _ald_getdata(2 /* DRIFILE_WAVE */, no - 1);
|
||||
if (!dfile)
|
||||
return null;
|
||||
let ptr = Module.getValue(dfile + 8, '*');
|
||||
let size = Module.getValue(dfile, 'i32');
|
||||
let buf = Module.HEAPU8.buffer.slice(ptr, ptr + size);
|
||||
_ald_freedata(dfile);
|
||||
return buf;
|
||||
}
|
||||
|
||||
pcm_load(slot: number, no: number) {
|
||||
EmterpreterAsync.handle((resume) => {
|
||||
this.pcm_stop(slot);
|
||||
if (this.buffers[no]) {
|
||||
this.slots[slot] = new PCMSoundSimple(this.masterGain, this.buffers[no]);
|
||||
return resume();
|
||||
}
|
||||
this.load(no).then((audioBuf) => {
|
||||
this.slots[slot] = new PCMSoundSimple(this.masterGain, audioBuf);
|
||||
resume();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pcm_load_mixlr(slot: number, noL: number, noR: number) {
|
||||
EmterpreterAsync.handle((resume) => {
|
||||
this.pcm_stop(slot);
|
||||
if (this.buffers[noL] && this.buffers[noR]) {
|
||||
this.slots[slot] = new PCMSoundMixLR(this.masterGain, this.buffers[noL], this.buffers[noR]);
|
||||
return resume();
|
||||
}
|
||||
let ps = [];
|
||||
if (!this.buffers[noL]) ps.push(this.load(noL));
|
||||
if (!this.buffers[noR]) ps.push(this.load(noR));
|
||||
Promise.all(ps).then(() => {
|
||||
this.slots[slot] = new PCMSoundMixLR(this.masterGain, this.buffers[noL], this.buffers[noR]);
|
||||
resume();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pcm_start(slot: number, loop: number): number {
|
||||
if (this.slots[slot]) {
|
||||
this.slots[slot].start(loop);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
pcm_stop(slot: number): number {
|
||||
if (!this.slots[slot])
|
||||
return 0;
|
||||
this.slots[slot].stop();
|
||||
this.slots[slot] = null;
|
||||
return 1;
|
||||
}
|
||||
|
||||
pcm_fadeout(slot: number, msec: number): number {
|
||||
if (!this.slots[slot])
|
||||
return 0;
|
||||
this.slots[slot].fadeout(msec);
|
||||
return 1;
|
||||
}
|
||||
|
||||
pcm_getpos(slot: number): number {
|
||||
if (!this.slots[slot])
|
||||
return 0;
|
||||
return this.slots[slot].getPosition() * 1000;
|
||||
}
|
||||
|
||||
pcm_setvol(slot: number, vol: number): number {
|
||||
if (!this.slots[slot])
|
||||
return 0;
|
||||
this.slots[slot].setGain(vol / 100);
|
||||
return 1;
|
||||
}
|
||||
|
||||
pcm_getwavelen(slot: number): number {
|
||||
if (!this.slots[slot])
|
||||
return 0;
|
||||
return this.slots[slot].duration * 1000;
|
||||
}
|
||||
|
||||
pcm_isplaying(slot: number): number {
|
||||
if (!this.slots[slot])
|
||||
return 0;
|
||||
return this.slots[slot].isPlaying() ? 1 : 0;
|
||||
}
|
||||
|
||||
private onVolumeChanged(evt: CustomEvent) {
|
||||
this.masterGain.gain.value = evt.detail;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class PCMSound {
|
||||
protected context: AudioContext;
|
||||
protected gain: GainNode;
|
||||
protected startTime: number;
|
||||
|
||||
constructor(protected dst: AudioNode) {
|
||||
this.context = dst.context;
|
||||
this.gain = this.context.createGain();
|
||||
this.gain.connect(dst);
|
||||
}
|
||||
abstract start(loop: number): void;
|
||||
abstract stop(): void;
|
||||
setGain(gain: number) {
|
||||
this.gain.gain.value = gain;
|
||||
}
|
||||
fadeout(msec: number) {
|
||||
this.gain.gain.linearRampToValueAtTime(0, this.context.currentTime + msec / 1000);
|
||||
}
|
||||
getPosition(): number {
|
||||
if (!this.startTime)
|
||||
return 0;
|
||||
return this.context.currentTime - this.startTime;
|
||||
}
|
||||
isPlaying(): boolean {
|
||||
return !!this.startTime;
|
||||
}
|
||||
abstract get duration(): number;
|
||||
}
|
||||
|
||||
class PCMSoundSimple extends PCMSound {
|
||||
private node: AudioBufferSourceNode;
|
||||
|
||||
constructor(dst: AudioNode, buf: AudioBuffer) {
|
||||
super(dst);
|
||||
this.node = this.context.createBufferSource();
|
||||
this.node.buffer = buf;
|
||||
this.node.connect(this.gain);
|
||||
this.node.onended = this.onended.bind(this);
|
||||
}
|
||||
|
||||
start(loop: number) {
|
||||
if (loop === 0)
|
||||
this.node.loop = true;
|
||||
else if (loop !== 1)
|
||||
console.warn('Unsupported PCM loop count ' + loop);
|
||||
this.node.start();
|
||||
this.startTime = this.context.currentTime;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.startTime) {
|
||||
this.node.stop();
|
||||
this.startTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return this.node.buffer.duration;
|
||||
}
|
||||
|
||||
private onended() {
|
||||
this.startTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
class PCMSoundMixLR extends PCMSound {
|
||||
private lsrc: AudioBufferSourceNode;
|
||||
private rsrc: AudioBufferSourceNode;
|
||||
private endCount = 0;
|
||||
|
||||
constructor(dst: AudioNode, lbuf: AudioBuffer, rbuf: AudioBuffer) {
|
||||
super(dst);
|
||||
this.lsrc = this.context.createBufferSource();
|
||||
this.rsrc = this.context.createBufferSource();
|
||||
this.lsrc.buffer = lbuf;
|
||||
this.rsrc.buffer = rbuf;
|
||||
let merger = this.context.createChannelMerger(2);
|
||||
merger.connect(this.gain);
|
||||
this.lsrc.connect(merger, 0, 0);
|
||||
this.rsrc.connect(merger, 0, 1);
|
||||
this.lsrc.onended = this.rsrc.onended = this.onended.bind(this);
|
||||
}
|
||||
|
||||
start(loop: number) {
|
||||
if (loop !== 1)
|
||||
console.warn('PCMSoundMixLR: loop is not supported ' + loop);
|
||||
this.lsrc.start();
|
||||
this.rsrc.start();
|
||||
this.startTime = this.context.currentTime;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.startTime) {
|
||||
this.lsrc.stop();
|
||||
this.rsrc.stop();
|
||||
this.startTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return Math.max(this.lsrc.buffer.duration, this.rsrc.buffer.duration);
|
||||
}
|
||||
|
||||
private onended() {
|
||||
this.endCount++;
|
||||
if (this.endCount === 2)
|
||||
this.startTime = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// <reference path="util.ts" />
|
||||
/// <reference path="volume.ts" />
|
||||
|
||||
namespace xsystem35 {
|
||||
export class CDPlayer {
|
||||
private audio = <HTMLAudioElement>$('audio');
|
||||
private blobs: Blob[];
|
||||
private currentTrack: number;
|
||||
waiting: boolean;
|
||||
|
||||
constructor(private imageLoader: ImageLoader, volumeControl: VolumeControl) {
|
||||
this.blobs = [];
|
||||
volumeControl.addEventListener(this.onVolumeChanged.bind(this));
|
||||
this.audio.volume = volumeControl.volume();
|
||||
this.waiting = false;
|
||||
this.removeUserGestureRestriction();
|
||||
}
|
||||
|
||||
play(track: number, loop: number) {
|
||||
this.currentTrack = track;
|
||||
this.waiting = true;
|
||||
if (this.blobs[track]) {
|
||||
this.startPlayback(this.blobs[track], loop);
|
||||
return;
|
||||
}
|
||||
this.imageLoader.getCDDA(track, (blob) => {
|
||||
this.blobs[track] = blob;
|
||||
this.startPlayback(blob, loop);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.audio.pause();
|
||||
this.currentTrack = null;
|
||||
}
|
||||
|
||||
getPosition(): number {
|
||||
if (!this.currentTrack)
|
||||
return 0;
|
||||
let time = Math.round(this.audio.currentTime * 75);
|
||||
return this.currentTrack | time << 8;
|
||||
}
|
||||
|
||||
private startPlayback(blob: Blob, loop: number) {
|
||||
this.audio.setAttribute('src', URL.createObjectURL(blob));
|
||||
this.audio.loop = (loop !== 0);
|
||||
this.audio.load();
|
||||
this.audio.play();
|
||||
this.waiting = false;
|
||||
}
|
||||
|
||||
private onVolumeChanged(evt: CustomEvent) {
|
||||
this.audio.volume = evt.detail;
|
||||
}
|
||||
|
||||
private removeUserGestureRestriction() {
|
||||
let hanlder = () => {
|
||||
this.audio.load();
|
||||
console.log('CDDA unlocked');
|
||||
window.removeEventListener('touchend', hanlder);
|
||||
};
|
||||
window.addEventListener('touchend', hanlder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/// <reference path="text-decoder.ts" />
|
||||
|
||||
class ISO9660FileSystem {
|
||||
static async create(sectorReader: CDImageReader): Promise<ISO9660FileSystem> {
|
||||
let pvd = new PVD(await sectorReader.readSector(0x10));
|
||||
return new ISO9660FileSystem(sectorReader, pvd);
|
||||
}
|
||||
|
||||
private constructor(private sectorReader: CDImageReader, private pvd: PVD) {
|
||||
if (this.pvd.type !== 1)
|
||||
throw('PVD not found');
|
||||
}
|
||||
|
||||
volumeLabel(): string {
|
||||
return this.pvd.volumeLabel();
|
||||
}
|
||||
|
||||
rootDir(): DirEnt {
|
||||
return this.pvd.rootDirEnt();
|
||||
}
|
||||
|
||||
async getDirEnt(name: string, parent: DirEnt): Promise<DirEnt> {
|
||||
name = name.toLowerCase();
|
||||
for (let e of await this.readDir(parent)) {
|
||||
if (e.name.toLowerCase() === name)
|
||||
return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async readDir(dirent: DirEnt): Promise<DirEnt[]> {
|
||||
let sector = dirent.sector;
|
||||
let position = 0;
|
||||
let length = dirent.size;
|
||||
let entries: DirEnt[] = [];
|
||||
let buf: ArrayBuffer;
|
||||
while (position < length) {
|
||||
if (position === 0)
|
||||
buf = await this.sectorReader.readSector(sector);
|
||||
let child = new DirEnt(buf, position);
|
||||
if (child.length === 0) {
|
||||
// Padded end of sector
|
||||
position = 2048;
|
||||
} else {
|
||||
entries.push(child);
|
||||
position += child.length;
|
||||
}
|
||||
if (position > 2048)
|
||||
throw('dirent across sector boundary');
|
||||
if (position === 2048) {
|
||||
sector++;
|
||||
position = 0;
|
||||
length -= 2048;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
readFile(dirent: DirEnt): Promise<Uint8Array[]> {
|
||||
return this.sectorReader.readSequentialSectors(dirent.sector, dirent.size);
|
||||
}
|
||||
}
|
||||
|
||||
class PVD {
|
||||
private view: DataView;
|
||||
constructor(private buf: ArrayBuffer) {
|
||||
this.view = new DataView(buf);
|
||||
}
|
||||
get type(): number {
|
||||
return this.view.getUint8(0);
|
||||
}
|
||||
volumeLabel(): string {
|
||||
let decoder = new TextDecoder('shift_jis');
|
||||
return decoder.decode(new DataView(this.buf, 40, 32)).trim();
|
||||
}
|
||||
rootDirEnt(): DirEnt {
|
||||
return new DirEnt(this.buf, 156);
|
||||
}
|
||||
}
|
||||
|
||||
class DirEnt {
|
||||
private view: DataView;
|
||||
constructor(private buf: ArrayBuffer, private offset: number) {
|
||||
this.view = new DataView(buf, offset);
|
||||
}
|
||||
get length(): number {
|
||||
return this.view.getUint8(0);
|
||||
}
|
||||
get sector(): number {
|
||||
return this.view.getUint32(2, true);
|
||||
}
|
||||
get size(): number {
|
||||
return this.view.getUint32(10, true);
|
||||
}
|
||||
get isDirectory(): boolean {
|
||||
return (this.view.getUint8(25) & 2) !== 0;
|
||||
}
|
||||
get name(): string {
|
||||
let len = this.view.getUint8(32);
|
||||
let decoder = new TextDecoder('shift_jis');
|
||||
return decoder.decode(new DataView(this.buf, this.offset + 33, len)).split(';')[0];
|
||||
}
|
||||
}
|
||||
|
||||
interface CDImageReader {
|
||||
readSector(sector: number): Promise<ArrayBuffer>;
|
||||
readSequentialSectors(startSector: number, length: number): Promise<Uint8Array[]>;
|
||||
maxTrack(): number;
|
||||
extractTrack(track: number): Promise<Blob>;
|
||||
}
|
||||
|
||||
function readAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let reader = new FileReader();
|
||||
reader.onload = () => { resolve(reader.result); };
|
||||
reader.onerror = () => { reject(reader.error); };
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function readAsText(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let reader = new FileReader();
|
||||
reader.onload = () => { resolve(reader.result); };
|
||||
reader.onerror = () => { reject(reader.error); };
|
||||
reader.readAsText(blob);
|
||||
});
|
||||
}
|
||||
|
||||
class ImageReaderBase {
|
||||
constructor(public image: File) {}
|
||||
|
||||
async readSequential(startOffset: number,
|
||||
bytesToRead: number,
|
||||
blockSize: number,
|
||||
sectorSize: number,
|
||||
sectorOffset: number): Promise<Uint8Array[]> {
|
||||
let sectors = Math.ceil(bytesToRead / sectorSize);
|
||||
let blob = this.image.slice(startOffset, startOffset + sectors * blockSize);
|
||||
let buf = await readAsArrayBuffer(blob);
|
||||
let bufs: Uint8Array[] = [];
|
||||
for (let i = 0; i < sectors; i++) {
|
||||
bufs.push(new Uint8Array(buf, i * blockSize + sectorOffset, Math.min(bytesToRead, sectorSize)));
|
||||
bytesToRead -= sectorSize;
|
||||
}
|
||||
return bufs;
|
||||
}
|
||||
}
|
||||
|
||||
class ImgCueReader extends ImageReaderBase implements CDImageReader {
|
||||
private tracks: Array<{ type: string; index: string[]; }>;
|
||||
|
||||
static async create(img: File, cue: File): Promise<ImgCueReader> {
|
||||
let reader = new ImgCueReader(img);
|
||||
await reader.parseCue(cue);
|
||||
return reader;
|
||||
}
|
||||
|
||||
private constructor(img: File) {
|
||||
super(img);
|
||||
}
|
||||
|
||||
readSector(sector: number): Promise<ArrayBuffer> {
|
||||
let start = sector * 2352 + 16;
|
||||
let end = start + 2048;
|
||||
return readAsArrayBuffer(this.image.slice(start, end));
|
||||
}
|
||||
|
||||
readSequentialSectors(startSector: number, length: number): Promise<Uint8Array[]> {
|
||||
return this.readSequential(startSector * 2352, length, 2352, 2048, 16);
|
||||
}
|
||||
|
||||
private async parseCue(cueFile: File) {
|
||||
let lines = (await readAsText(cueFile)).split('\n');
|
||||
this.tracks = [];
|
||||
let currentTrack: number = null;
|
||||
for (let line of lines) {
|
||||
let fields = line.trim().split(/\s+/);
|
||||
switch (fields[0]) {
|
||||
case 'TRACK':
|
||||
currentTrack = Number(fields[1]);
|
||||
this.tracks[currentTrack] = {type: fields[2], index: []};
|
||||
break;
|
||||
case 'INDEX':
|
||||
if (currentTrack)
|
||||
this.tracks[currentTrack].index[Number(fields[1])] = fields[2];
|
||||
break;
|
||||
default:
|
||||
; // Do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
maxTrack(): number {
|
||||
return this.tracks.length - 1;
|
||||
}
|
||||
|
||||
async extractTrack(track: number): Promise<Blob> {
|
||||
if (!this.tracks[track] || this.tracks[track].type !== 'AUDIO')
|
||||
return;
|
||||
|
||||
let start = this.indexToSector(this.tracks[track].index[1]) * 2352;
|
||||
let end: number;
|
||||
if (this.tracks[track + 1]) {
|
||||
let index = this.tracks[track + 1].index[0] || this.tracks[track + 1].index[1];
|
||||
end = this.indexToSector(index) * 2352;
|
||||
} else {
|
||||
end = this.image.size;
|
||||
}
|
||||
let size = end - start;
|
||||
return new Blob([createWaveHeader(size), this.image.slice(start, start + size)], {type : 'audio/wav'});
|
||||
}
|
||||
|
||||
private indexToSector(index: string): number {
|
||||
let msf = index.split(':').map(Number);
|
||||
return msf[0] * 60 * 75 + msf[1] * 75 + msf[2];
|
||||
}
|
||||
}
|
||||
|
||||
enum MdsTrackMode { Audio = 0xa9, Mode1 = 0xaa };
|
||||
|
||||
class MdfMdsReader extends ImageReaderBase implements CDImageReader {
|
||||
private tracks: Array<{ mode: number; sectorSize: number; offset: number; sectors: number; }>;
|
||||
|
||||
static async create(mdf: File, mds: File): Promise<MdfMdsReader> {
|
||||
let reader = new MdfMdsReader(mdf);
|
||||
await reader.parseMds(mds);
|
||||
return reader;
|
||||
}
|
||||
|
||||
private constructor(mdf: File) {
|
||||
super(mdf);
|
||||
}
|
||||
|
||||
private async parseMds(mdsFile: File) {
|
||||
let buf = await readAsArrayBuffer(mdsFile);
|
||||
|
||||
let signature = new TextDecoder().decode(new DataView(buf, 0, 16));
|
||||
if (signature !== 'MEDIA DESCRIPTOR')
|
||||
throw mdsFile.name + ': not a mds file';
|
||||
|
||||
let header = new DataView(buf, 0, 0x70);
|
||||
let entries = header.getUint8(0x62);
|
||||
|
||||
this.tracks = [];
|
||||
for (let i = 0; i < entries; i++) {
|
||||
let trackData = new DataView(buf, 0x70 + i * 0x50, 0x50);
|
||||
let extraData = new DataView(buf, 0x70 + entries * 0x50 + i * 8, 8);
|
||||
let mode = trackData.getUint8(0x00);
|
||||
let track = trackData.getUint8(0x04);
|
||||
let sectorSize = trackData.getUint16(0x10, true);
|
||||
let offset = trackData.getUint32(0x28, true); // >4GB offset is not supported.
|
||||
let sectors = extraData.getUint32(0x4, true);
|
||||
if (track < 100)
|
||||
this.tracks[track] = {mode, sectorSize, offset, sectors};
|
||||
}
|
||||
if (this.tracks[1].mode !== MdsTrackMode.Mode1)
|
||||
throw 'track 1 is not mode1';
|
||||
}
|
||||
|
||||
readSector(sector: number): Promise<ArrayBuffer> {
|
||||
let start = sector * this.tracks[1].sectorSize + 16;
|
||||
let end = start + 2048;
|
||||
return readAsArrayBuffer(this.image.slice(start, end));
|
||||
}
|
||||
|
||||
readSequentialSectors(startSector: number, length: number): Promise<Uint8Array[]> {
|
||||
let track = this.tracks[1];
|
||||
return this.readSequential(track.offset + startSector * track.sectorSize, length, track.sectorSize, 2048, 16);
|
||||
}
|
||||
|
||||
maxTrack(): number {
|
||||
return this.tracks.length - 1;
|
||||
}
|
||||
|
||||
async extractTrack(track: number): Promise<Blob> {
|
||||
if (!this.tracks[track] || this.tracks[track].mode !== MdsTrackMode.Audio)
|
||||
return;
|
||||
|
||||
let size = this.tracks[track].sectors * 2352;
|
||||
let chunks = await this.readSequential(this.tracks[track].offset, size, this.tracks[track].sectorSize, 2352, 0);
|
||||
return new Blob([<any>createWaveHeader(size)].concat(chunks), {type : 'audio/wav'});
|
||||
}
|
||||
}
|
||||
|
||||
function createWaveHeader(size: number): ArrayBuffer {
|
||||
let buf = new ArrayBuffer(44);
|
||||
let view = new DataView(buf);
|
||||
view.setUint32(0, 0x52494646, false); // 'RIFF'
|
||||
view.setUint32(4, size + 36, true); // filesize - 8
|
||||
view.setUint32(8, 0x57415645, false); // 'WAVE'
|
||||
view.setUint32(12, 0x666D7420, false); // 'fmt '
|
||||
view.setUint32(16, 16, true); // size of fmt chunk
|
||||
view.setUint16(20, 1, true); // PCM format
|
||||
view.setUint16(22, 2, true); // stereo
|
||||
view.setUint32(24, 44100, true); // sampling rate
|
||||
view.setUint32(28, 176400, true); // bytes/sec
|
||||
view.setUint16(32, 4, true); // block size
|
||||
view.setUint16(34, 16, true); // bit/sample
|
||||
view.setUint32(36, 0x64617461, false); // 'data'
|
||||
view.setUint32(40, size, true); // data size
|
||||
return buf;
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/// <reference path="util.ts" />
|
||||
/// <reference path="cdimage.ts" />
|
||||
|
||||
namespace xsystem35 {
|
||||
export class ImageLoader {
|
||||
private imgFile: File;
|
||||
private cueFile: File;
|
||||
private imageReader: CDImageReader;
|
||||
|
||||
constructor(private runMain: () => void) {
|
||||
$('#fileselect').addEventListener('change', this.handleFileSelect.bind(this), false);
|
||||
document.body.ondragover = this.handleDragOver.bind(this);
|
||||
document.body.ondrop = this.handleDrop.bind(this);
|
||||
}
|
||||
|
||||
async getCDDA(track: number, callback: (wav: Blob) => void) {
|
||||
let blob = await this.imageReader.extractTrack(track);
|
||||
callback(blob);
|
||||
}
|
||||
|
||||
private handleFileSelect(evt: Event) {
|
||||
let input = <HTMLInputElement>evt.target;
|
||||
let files = input.files;
|
||||
for (let i = 0; i < files.length; i++)
|
||||
this.setFile(files[i]);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
private handleDragOver(evt: DragEvent) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
evt.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
private handleDrop(evt: DragEvent) {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
let files = evt.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++)
|
||||
this.setFile(files[i]);
|
||||
}
|
||||
|
||||
private async setFile(file: File) {
|
||||
let name = file.name.toLowerCase();
|
||||
if (name.endsWith('.img') || name.endsWith('.mdf')) {
|
||||
this.imgFile = file;
|
||||
$('#imgReady').classList.remove('notready');
|
||||
$('#imgReady').textContent = file.name;
|
||||
} else if (name.endsWith('.cue') || name.endsWith('.mds')) {
|
||||
this.cueFile = file;
|
||||
$('#cueReady').classList.remove('notready');
|
||||
$('#cueReady').textContent = file.name;
|
||||
}
|
||||
if (this.imgFile && this.cueFile) {
|
||||
if (this.cueFile.name.endsWith('.cue'))
|
||||
this.imageReader = await ImgCueReader.create(this.imgFile, this.cueFile);
|
||||
else
|
||||
this.imageReader = await MdfMdsReader.create(this.imgFile, this.cueFile);
|
||||
await this.installAndRun();
|
||||
}
|
||||
}
|
||||
|
||||
private async extractFile(isofs: ISO9660FileSystem, entry: DirEnt): Promise<ArrayBuffer> {
|
||||
let buffer = new ArrayBuffer(entry.size);
|
||||
let uint8 = new Uint8Array(buffer);
|
||||
let ptr = 0;
|
||||
for (let buf of await isofs.readFile(entry)) {
|
||||
uint8.set(buf, ptr);
|
||||
ptr += buf.byteLength;
|
||||
}
|
||||
if (ptr !== entry.size)
|
||||
throw ('expected ' + entry.size + ' bytes, but read ' + ptr + 'bytes');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private async installAndRun() {
|
||||
let isofs = await ISO9660FileSystem.create(this.imageReader);
|
||||
// this.walk(isofs, isofs.rootDir(), '/');
|
||||
let gamedata = await isofs.getDirEnt('gamedata', isofs.rootDir());
|
||||
if (!gamedata) {
|
||||
this.setError('インストールできません。GAMEDATAフォルダが見つかりません。');
|
||||
return;
|
||||
}
|
||||
let grGenerator = new GameResourceGenerator();
|
||||
for (let e of await isofs.readDir(gamedata)) {
|
||||
if (e.name.toLowerCase().endsWith('.ald')) {
|
||||
let data = await this.extractFile(isofs, e);
|
||||
// Store contents in the emscripten heap, so that it can be mmap-ed without copying
|
||||
let ptr = Module.getMemory(data.byteLength);
|
||||
Module.HEAPU8.set(new Uint8Array(data), ptr);
|
||||
FS.writeFile(e.name, Module.HEAPU8.subarray(ptr, ptr + data.byteLength),
|
||||
{ encoding: 'binary', canOwn: true });
|
||||
grGenerator.addFile(e.name);
|
||||
}
|
||||
}
|
||||
FS.writeFile('xsystem35.gr', grGenerator.generate());
|
||||
FS.writeFile('.xsys35rc', xsystem35.xsys35rc);
|
||||
this.runMain();
|
||||
}
|
||||
|
||||
private setError(msg: string) {
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
// For debug
|
||||
private async walk(isofs: ISO9660FileSystem, dir: DirEnt, dirname: string) {
|
||||
for (let e of await isofs.readDir(dir)) {
|
||||
if (e.name !== '\0' && e.name !== '\x01') {
|
||||
console.log(dirname + e.name);
|
||||
if (e.isDirectory)
|
||||
this.walk(isofs, e, dirname + e.name + '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GameResourceGenerator {
|
||||
static resourceType: { [ch: string]: string } =
|
||||
{ s: 'Scenario', g: 'Graphics', w: 'Wave', d: 'Data', r: 'Resource', m: 'Midi' };
|
||||
private basename: string;
|
||||
private lines: string[] = [];
|
||||
|
||||
addFile(name: string) {
|
||||
let type = name.charAt(name.length - 6).toLowerCase();
|
||||
let id = name.charAt(name.length - 5);
|
||||
this.basename = name.slice(0, -6);
|
||||
this.lines.push(GameResourceGenerator.resourceType[type] + id.toUpperCase() + ' ' + name);
|
||||
}
|
||||
|
||||
generate(): string {
|
||||
for (let i = 0; i < 26; i++) {
|
||||
let id = String.fromCharCode(65 + i);
|
||||
this.lines.push('Save' + id + ' save/' + this.basename + 's' + id.toLowerCase() + '.asd');
|
||||
}
|
||||
return this.lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.lines.length === 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "xsystem35-shell",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "0.0.29",
|
||||
"@types/text-encoding": "0.0.30"
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/// <reference path="util.ts" />
|
||||
/// <reference path="loader.ts" />
|
||||
/// <reference path="zoom.ts" />
|
||||
/// <reference path="volume.ts" />
|
||||
/// <reference path="cdda.ts" />
|
||||
/// <reference path="audio.ts" />
|
||||
|
||||
namespace xsystem35 {
|
||||
const Font = { url: 'fonts/MTLc3m.ttf', fname: 'MTLc3m.ttf'};
|
||||
export const xsys35rc = [
|
||||
'font_device: ttf',
|
||||
'ttfont_mincho: ' + Font.fname,
|
||||
'ttfont_gothic: ' + Font.fname, '',
|
||||
].join('\n');
|
||||
export let cdPlayer: CDPlayer;
|
||||
export let audio: AudioManager;
|
||||
|
||||
class System35Shell {
|
||||
private imageLoader: ImageLoader;
|
||||
status: HTMLElement = document.getElementById('status');
|
||||
private zoom: ZoomManager;
|
||||
private volumeControl: VolumeControl;
|
||||
private antialiasCheckbox: HTMLInputElement;
|
||||
|
||||
constructor() {
|
||||
this.imageLoader = new ImageLoader(this.run.bind(this));
|
||||
this.setStatus('Downloading...');
|
||||
window.onerror = () => {
|
||||
this.setStatus('Exception thrown, see JavaScript console');
|
||||
this.setStatus = (text: string) => {
|
||||
if (text) Module.printErr('[post-exception status] ' + text);
|
||||
};
|
||||
};
|
||||
|
||||
// Initialize the Module object
|
||||
Module.TOTAL_MEMORY = 96 * 1024 * 1024;
|
||||
Module.print = Module.printErr = console.log.bind(console);
|
||||
Module.canvas = document.getElementById('canvas');
|
||||
Module.noInitialRun = true;
|
||||
Module.setStatus = this.setStatus.bind(this);
|
||||
Module.preRun = [
|
||||
function loadFont() {
|
||||
FS.createPreloadedFile('/', Font.fname, Font.url, true, false);
|
||||
},
|
||||
function prepareSaveDir() {
|
||||
FS.mkdir('/save');
|
||||
FS.mount(IDBFS, {}, '/save');
|
||||
Module.addRunDependency('syncfs');
|
||||
FS.syncfs(true, (err) => { Module.removeRunDependency('syncfs'); });
|
||||
},
|
||||
];
|
||||
this.volumeControl = new VolumeControl();
|
||||
xsystem35.cdPlayer = new CDPlayer(this.imageLoader, this.volumeControl);
|
||||
this.zoom = new ZoomManager();
|
||||
this.antialiasCheckbox = <HTMLInputElement>$('#antialias');
|
||||
this.antialiasCheckbox.addEventListener('change', this.antialiasChanged.bind(this));
|
||||
this.antialiasCheckbox.checked = localStorage.getItem('antialias') !== 'false';
|
||||
xsystem35.audio = new AudioManager(this.volumeControl);
|
||||
}
|
||||
|
||||
run() {
|
||||
$('#loader').hidden = true;
|
||||
$('#xsystem35').hidden = false;
|
||||
setTimeout(() => {
|
||||
Module.callMain();
|
||||
this.antialiasChanged();
|
||||
}, 0);
|
||||
this.addRightClickEmulation();
|
||||
}
|
||||
|
||||
setStatus(text: string) {
|
||||
console.log(text);
|
||||
this.status.innerHTML = text;
|
||||
}
|
||||
|
||||
windowSizeChanged() {
|
||||
this.zoom.handleZoom();
|
||||
}
|
||||
|
||||
private fsyncTimer: number;
|
||||
syncfs(fname: string) {
|
||||
window.clearTimeout(this.fsyncTimer);
|
||||
this.fsyncTimer = window.setTimeout(() => {
|
||||
FS.syncfs(false, (err) => {
|
||||
if (err)
|
||||
console.log('FS.syncfs error: ', err);
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private antialiasChanged() {
|
||||
localStorage.setItem('antialias', String(this.antialiasCheckbox.checked));
|
||||
_ags_setAntialiasedStringMode(this.antialiasCheckbox.checked ? 1 : 0);
|
||||
}
|
||||
|
||||
private addRightClickEmulation() {
|
||||
let emulatingRightClick = false;
|
||||
document.body.addEventListener('touchstart', (e) => {
|
||||
if (e.target !== document.body)
|
||||
return;
|
||||
_sdl_rightButton(1);
|
||||
emulatingRightClick = true;
|
||||
});
|
||||
document.body.addEventListener('touchend', (e) => {
|
||||
if (!emulatingRightClick)
|
||||
return;
|
||||
_sdl_rightButton(0);
|
||||
emulatingRightClick = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export let shell = new System35Shell();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
class FallbackTextDecoder {
|
||||
constructor(utfLabel?: string) {}
|
||||
|
||||
decode(buffer: DataView): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < buffer.byteLength; i++) {
|
||||
let c = buffer.getUint8(i);
|
||||
if (c < 128)
|
||||
out += String.fromCharCode(c);
|
||||
else
|
||||
out += '%' + c.toString(16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(<any>window).TextDecoder)
|
||||
(<any>window).TextDecoder = <TextEncoding.TextDecoderStatic>FallbackTextDecoder;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"outFile": "../docs/shell.js",
|
||||
"noImplicitAny": true,
|
||||
"sourceMap": false
|
||||
},
|
||||
"files": [
|
||||
"shell.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "tslint:recommended",
|
||||
"rules": {
|
||||
"curly": false,
|
||||
"interface-name": false,
|
||||
"max-classes-per-file": false,
|
||||
"member-access": false,
|
||||
"member-ordering": false,
|
||||
"no-bitwise": false,
|
||||
"no-empty": false,
|
||||
"no-namespace": false,
|
||||
"no-reference": false,
|
||||
"only-arrow-functions": ["allow-declarations", "allow-named-functions"],
|
||||
"prefer-for-of": false,
|
||||
"quotemark": [true, "single", "avoid-escape"],
|
||||
"whitespace": [
|
||||
true,
|
||||
"check-branch",
|
||||
"check-decl",
|
||||
"check-operator",
|
||||
"check-separator",
|
||||
"check-type"
|
||||
],
|
||||
|
||||
"no-console": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
let $: (selector: string) => HTMLElement = document.querySelector.bind(document);
|
||||
|
||||
// xsystem35 exported functions
|
||||
declare function _musfade_setvolval_all(vol: number): void;
|
||||
declare function _ags_setAntialiasedStringMode(on: number): void;
|
||||
declare function _sdl_rightButton(down: number): void;
|
||||
declare function _ald_getdata(type: number, no: number): number;
|
||||
declare function _ald_freedata(data: number): void;
|
||||
|
||||
declare namespace Module {
|
||||
let noInitialRun: boolean;
|
||||
|
||||
// Undocumented methods / attributes
|
||||
let canvas: HTMLElement;
|
||||
function setStatus(status: string): void;
|
||||
function monitorRunDependencies(left: number): void;
|
||||
function callMain(): void;
|
||||
function getMemory(size: number): number;
|
||||
}
|
||||
|
||||
declare namespace FS {
|
||||
function writeFile(path: string, data: ArrayBufferView | string,
|
||||
opts?: {encoding?: string; flags?: string; canOwn?: boolean}): void;
|
||||
}
|
||||
|
||||
declare namespace EmterpreterAsync {
|
||||
function handle(asyncOp: (resume: () => void) => void): void;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/// <reference path="util.ts" />
|
||||
|
||||
namespace xsystem35 {
|
||||
export class VolumeControl {
|
||||
private vol: number; // 0.0 - 1.0
|
||||
private muted: boolean;
|
||||
private elem: HTMLElement;
|
||||
private icon: HTMLElement;
|
||||
private slider: HTMLInputElement;
|
||||
|
||||
constructor() {
|
||||
this.vol = Number(localStorage.getItem('volume') || 1);
|
||||
this.muted = false;
|
||||
|
||||
this.elem = $('#volume-control');
|
||||
this.icon = $('#volume-control-icon');
|
||||
this.slider = <HTMLInputElement>$('#volume-control-slider');
|
||||
this.slider.value = String(Math.round(this.vol * 100));
|
||||
|
||||
this.icon.addEventListener('click', this.onIconClicked.bind(this));
|
||||
this.slider.addEventListener('input', this.onSliderValueChanged.bind(this));
|
||||
this.slider.addEventListener('change', this.onSliderValueSettled.bind(this));
|
||||
}
|
||||
|
||||
volume(): number {
|
||||
return this.muted ? 0 : parseInt(this.slider.value, 10) / 100;
|
||||
}
|
||||
|
||||
addEventListener(handler: (evt: CustomEvent) => any) {
|
||||
this.elem.addEventListener('volumechange', handler);
|
||||
}
|
||||
|
||||
private onIconClicked(e: Event) {
|
||||
this.muted = !this.muted;
|
||||
if (this.muted) {
|
||||
this.icon.classList.remove('fa-volume-up');
|
||||
this.icon.classList.add('fa-volume-off');
|
||||
this.slider.value = '0';
|
||||
} else {
|
||||
this.icon.classList.remove('fa-volume-off');
|
||||
this.icon.classList.add('fa-volume-up');
|
||||
this.slider.value = String(Math.round(this.vol * 100));
|
||||
}
|
||||
this.dispatchEvent();
|
||||
}
|
||||
|
||||
private onSliderValueChanged(e: Event) {
|
||||
this.vol = parseInt(this.slider.value, 10) / 100;
|
||||
if (this.vol > 0 && this.muted) {
|
||||
this.muted = false;
|
||||
this.icon.classList.remove('fa-volume-off');
|
||||
this.icon.classList.add('fa-volume-up');
|
||||
}
|
||||
this.dispatchEvent();
|
||||
}
|
||||
|
||||
private onSliderValueSettled(e: Event) {
|
||||
localStorage.setItem('volume', this.vol + '');
|
||||
}
|
||||
|
||||
private dispatchEvent() {
|
||||
let event = new CustomEvent('volumechange', { detail: this.volume() });
|
||||
this.elem.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/// <reference path="util.ts" />
|
||||
|
||||
namespace xsystem35 {
|
||||
export class ZoomManager {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private zoomSelect: HTMLInputElement;
|
||||
private smoothingCheckbox: HTMLInputElement;
|
||||
|
||||
constructor() {
|
||||
this.canvas = <HTMLCanvasElement>$('#canvas');
|
||||
this.zoomSelect = <HTMLInputElement>$('#zoom');
|
||||
this.zoomSelect.addEventListener('change', this.handleZoom.bind(this));
|
||||
this.zoomSelect.value = localStorage.getItem('zoom') || '1';
|
||||
this.smoothingCheckbox = <HTMLInputElement>$('#smoothing');
|
||||
this.smoothingCheckbox.addEventListener('change', this.handleSmoothing.bind(this));
|
||||
if (localStorage.getItem('smoothing') === 'false') {
|
||||
this.smoothingCheckbox.checked = false;
|
||||
this.handleSmoothing();
|
||||
}
|
||||
}
|
||||
|
||||
handleZoom() {
|
||||
let value = this.zoomSelect.value;
|
||||
localStorage.setItem('zoom', value);
|
||||
let contentsStyle = $('.contents').style;
|
||||
if (value === 'fit') {
|
||||
contentsStyle.maxWidth = 'none';
|
||||
contentsStyle.width = this.canvas.style.width = '100%';
|
||||
} else {
|
||||
let ratio = Number(value);
|
||||
contentsStyle.maxWidth = 'none';
|
||||
contentsStyle.width = this.canvas.style.width = this.canvas.width * ratio + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
private handleSmoothing() {
|
||||
localStorage.setItem('smoothing', String(this.smoothingCheckbox.checked));
|
||||
if (this.smoothingCheckbox.checked)
|
||||
this.canvas.classList.remove('pixelated');
|
||||
else
|
||||
this.canvas.classList.add('pixelated');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user