You've already forked Nitro_Render_V3
mirror of
https://github.com/duckietm/Nitro_Render_V3.git
synced 2026-06-19 15:06:20 +00:00
Merge remote-tracking branch 'duckie/main' into duckie-live-merge-2026-04-21
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
import { ICodec, IConnection, IMessageComposer, IMessageConfiguration, IMessageDataWrapper, IMessageEvent, WebSocketEventEnum } from '@nitrots/api';
|
import { ICodec, IConnection, IMessageComposer, IMessageConfiguration, IMessageDataWrapper, IMessageEvent, WebSocketEventEnum } from '@nitrots/api';
|
||||||
|
import { GetConfiguration } from '@nitrots/configuration';
|
||||||
import { GetEventDispatcher, NitroEvent, NitroEventType, ReconnectEvent } from '@nitrots/events';
|
import { GetEventDispatcher, NitroEvent, NitroEventType, ReconnectEvent } from '@nitrots/events';
|
||||||
import { NitroLogger } from '@nitrots/utils';
|
import { NitroLogger } from '@nitrots/utils';
|
||||||
import { EvaWireFormat } from './codec';
|
import { EvaWireFormat } from './codec';
|
||||||
|
import { aesGcmDecrypt, aesGcmEncrypt, buildClientHello, deriveAesKey, deriveSharedSecret, exportPublicKeySpki, generateEphemeralKeyPair, importPublicKeySpki, importSigningPublicKeyFromBase64, NONCE_LEN, parseServerHello, randomNonce, verifyEphemeralSignature } from './crypto';
|
||||||
import { MessageClassManager } from './messages';
|
import { MessageClassManager } from './messages';
|
||||||
|
|
||||||
|
type CryptoState = 'disabled' | 'awaiting_server_hello' | 'ready' | 'error';
|
||||||
|
|
||||||
export class SocketConnection implements IConnection
|
export class SocketConnection implements IConnection
|
||||||
{
|
{
|
||||||
private _socket: WebSocket = null;
|
private _socket: WebSocket = null;
|
||||||
@@ -24,11 +28,15 @@ export class SocketConnection implements IConnection
|
|||||||
private _isReconnecting: boolean = false;
|
private _isReconnecting: boolean = false;
|
||||||
private _intentionalClose: boolean = false;
|
private _intentionalClose: boolean = false;
|
||||||
private _wasAuthenticated: boolean = false;
|
private _wasAuthenticated: boolean = false;
|
||||||
|
|
||||||
public static readonly MAX_RECONNECT_ATTEMPTS: number = 7;
|
public static readonly MAX_RECONNECT_ATTEMPTS: number = 7;
|
||||||
public static readonly BASE_RECONNECT_DELAY_MS: number = 1000;
|
public static readonly BASE_RECONNECT_DELAY_MS: number = 1000;
|
||||||
public static readonly MAX_RECONNECT_DELAY_MS: number = 30000;
|
public static readonly MAX_RECONNECT_DELAY_MS: number = 30000;
|
||||||
|
|
||||||
|
private _cryptoState: CryptoState = 'disabled';
|
||||||
|
private _sessionKey: CryptoKey = null;
|
||||||
|
private _pendingEncryptedSends: ArrayBuffer[] = [];
|
||||||
|
|
||||||
public init(socketUrl: string): void
|
public init(socketUrl: string): void
|
||||||
{
|
{
|
||||||
if(!socketUrl || !socketUrl.length) return;
|
if(!socketUrl || !socketUrl.length) return;
|
||||||
@@ -42,24 +50,155 @@ export class SocketConnection implements IConnection
|
|||||||
private createSocket(socketUrl: string): void
|
private createSocket(socketUrl: string): void
|
||||||
{
|
{
|
||||||
this._dataBuffer = new ArrayBuffer(0);
|
this._dataBuffer = new ArrayBuffer(0);
|
||||||
|
const cryptoEnabled = !!GetConfiguration().getValue<boolean>('crypto.ws.enabled', false);
|
||||||
|
if(cryptoEnabled && !this.subtleCryptoAvailable())
|
||||||
|
{
|
||||||
|
NitroLogger.error('[ws-crypto] crypto.ws.enabled=true but window.crypto.subtle is unavailable. '
|
||||||
|
+ 'This page must be served from a secure context - HTTPS, localhost, or 127.0.0.1. '
|
||||||
|
+ 'Current origin: ' + (typeof window !== 'undefined' ? window.location.origin : 'unknown'));
|
||||||
|
this._cryptoState = 'error';
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._cryptoState = cryptoEnabled ? 'awaiting_server_hello' : 'disabled';
|
||||||
|
}
|
||||||
|
this._sessionKey = null;
|
||||||
|
this._pendingEncryptedSends = [];
|
||||||
|
|
||||||
this._socket = new WebSocket(socketUrl);
|
this._socket = new WebSocket(socketUrl);
|
||||||
this._socket.binaryType = 'arraybuffer';
|
this._socket.binaryType = 'arraybuffer';
|
||||||
this._onOpenCallback = () => this.onSocketOpened();
|
this._onOpenCallback = () => this.onSocketOpened();
|
||||||
this._onCloseCallback = (event: Event) => this.onSocketClosed(event as CloseEvent);
|
this._onCloseCallback = (event: Event) => this.onSocketClosed(event as CloseEvent);
|
||||||
this._onErrorCallback = () => this.onSocketError();
|
this._onErrorCallback = () => this.onSocketError();
|
||||||
this._onMessageCallback = (event: MessageEvent) =>
|
this._onMessageCallback = (event: MessageEvent) => this.onSocketMessage(event.data as ArrayBuffer);
|
||||||
{
|
|
||||||
this._dataBuffer = this.concatArrayBuffers(this._dataBuffer, event.data);
|
|
||||||
this.processReceivedData();
|
|
||||||
};
|
|
||||||
|
|
||||||
this._socket.addEventListener(WebSocketEventEnum.CONNECTION_OPENED, this._onOpenCallback);
|
this._socket.addEventListener(WebSocketEventEnum.CONNECTION_OPENED, this._onOpenCallback);
|
||||||
this._socket.addEventListener(WebSocketEventEnum.CONNECTION_CLOSED, this._onCloseCallback);
|
this._socket.addEventListener(WebSocketEventEnum.CONNECTION_CLOSED, this._onCloseCallback);
|
||||||
this._socket.addEventListener(WebSocketEventEnum.CONNECTION_ERROR, this._onErrorCallback);
|
this._socket.addEventListener(WebSocketEventEnum.CONNECTION_ERROR, this._onErrorCallback);
|
||||||
this._socket.addEventListener(WebSocketEventEnum.CONNECTION_MESSAGE, this._onMessageCallback);
|
this._socket.addEventListener(WebSocketEventEnum.CONNECTION_MESSAGE, this._onMessageCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private subtleCryptoAvailable(): boolean
|
||||||
|
{
|
||||||
|
return typeof window !== 'undefined'
|
||||||
|
&& typeof window.crypto !== 'undefined'
|
||||||
|
&& typeof window.crypto.subtle !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSocketMessage(data: ArrayBuffer): void
|
||||||
|
{
|
||||||
|
if(this._cryptoState === 'error')
|
||||||
|
{
|
||||||
|
this._intentionalClose = true;
|
||||||
|
if(this._socket) this._socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this._cryptoState === 'awaiting_server_hello')
|
||||||
|
{
|
||||||
|
this.handleServerHello(data)
|
||||||
|
.catch(err =>
|
||||||
|
{
|
||||||
|
NitroLogger.error('[ws-crypto] handshake failed', err);
|
||||||
|
this._cryptoState = 'error';
|
||||||
|
this._intentionalClose = true;
|
||||||
|
if(this._socket) this._socket.close();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this._cryptoState === 'ready')
|
||||||
|
{
|
||||||
|
this.decryptFrame(data)
|
||||||
|
.then(plain =>
|
||||||
|
{
|
||||||
|
this._dataBuffer = this.concatArrayBuffers(this._dataBuffer, plain);
|
||||||
|
this.processReceivedData();
|
||||||
|
})
|
||||||
|
.catch(err =>
|
||||||
|
{
|
||||||
|
NitroLogger.error('[ws-crypto] decrypt failed', err);
|
||||||
|
this._cryptoState = 'error';
|
||||||
|
this._intentionalClose = true;
|
||||||
|
if(this._socket) this._socket.close();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._dataBuffer = this.concatArrayBuffers(this._dataBuffer, data);
|
||||||
|
this.processReceivedData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleServerHello(frame: ArrayBuffer): Promise<void>
|
||||||
|
{
|
||||||
|
const { pubkeySpki: serverPubkeySpki, signature } = parseServerHello(frame);
|
||||||
|
const signingRequired = !!GetConfiguration().getValue<boolean>('crypto.ws.signing.enabled', false);
|
||||||
|
if(signingRequired)
|
||||||
|
{
|
||||||
|
if(!signature) throw new Error('crypto.ws.signing.enabled=true but server_hello had no signature');
|
||||||
|
|
||||||
|
const signingPub = await this.getSigningPublicKey();
|
||||||
|
const ok = await verifyEphemeralSignature(signingPub, signature, serverPubkeySpki);
|
||||||
|
if(!ok) throw new Error('server_hello signature verification failed (MITM?)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPubkey = await importPublicKeySpki(serverPubkeySpki);
|
||||||
|
const ourKeys = await generateEphemeralKeyPair();
|
||||||
|
const ourPubkeySpki = await exportPublicKeySpki(ourKeys.publicKey);
|
||||||
|
const shared = await deriveSharedSecret(ourKeys.privateKey, serverPubkey);
|
||||||
|
this._sessionKey = await deriveAesKey(shared);
|
||||||
|
this._socket.send(buildClientHello(ourPubkeySpki));
|
||||||
|
this._cryptoState = 'ready';
|
||||||
|
|
||||||
|
if(this._pendingEncryptedSends.length)
|
||||||
|
{
|
||||||
|
const queued = this._pendingEncryptedSends;
|
||||||
|
this._pendingEncryptedSends = [];
|
||||||
|
for(const buf of queued) await this.encryptAndSend(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cachedSigningPublicKey: CryptoKey = null;
|
||||||
|
private async getSigningPublicKey(): Promise<CryptoKey>
|
||||||
|
{
|
||||||
|
if(this._cachedSigningPublicKey) return this._cachedSigningPublicKey;
|
||||||
|
|
||||||
|
const pinned = GetConfiguration().getValue<string>('crypto.ws.signing.public_key', '');
|
||||||
|
if(pinned)
|
||||||
|
{
|
||||||
|
this._cachedSigningPublicKey = await importSigningPublicKeyFromBase64(pinned);
|
||||||
|
return this._cachedSigningPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointTemplate = GetConfiguration().getValue<string>('login.server_key.endpoint', '/api/auth/server-key');
|
||||||
|
const endpoint = GetConfiguration().interpolate(endpointTemplate);
|
||||||
|
const resp = await fetch(endpoint, { credentials: 'include' });
|
||||||
|
if(!resp.ok) throw new Error(`server-key fetch failed: HTTP ${ resp.status }`);
|
||||||
|
const payload = await resp.json();
|
||||||
|
const b64 = typeof payload?.publicKey === 'string' ? payload.publicKey : '';
|
||||||
|
if(!b64) throw new Error('server-key response missing publicKey');
|
||||||
|
this._cachedSigningPublicKey = await importSigningPublicKeyFromBase64(b64);
|
||||||
|
return this._cachedSigningPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async decryptFrame(frame: ArrayBuffer): Promise<ArrayBuffer>
|
||||||
|
{
|
||||||
|
if(frame.byteLength < NONCE_LEN + 16) throw new Error('encrypted frame too short');
|
||||||
|
const nonce = new Uint8Array(frame, 0, NONCE_LEN);
|
||||||
|
const ct = frame.slice(NONCE_LEN);
|
||||||
|
return aesGcmDecrypt(this._sessionKey, nonce, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async encryptAndSend(plaintext: ArrayBuffer): Promise<void>
|
||||||
|
{
|
||||||
|
if(!this._sessionKey) return;
|
||||||
|
const nonce = randomNonce();
|
||||||
|
const ct = await aesGcmEncrypt(this._sessionKey, nonce, plaintext);
|
||||||
|
const framed = new Uint8Array(NONCE_LEN + ct.byteLength);
|
||||||
|
framed.set(nonce, 0);
|
||||||
|
framed.set(new Uint8Array(ct), NONCE_LEN);
|
||||||
|
if(this._socket && this._socket.readyState === WebSocket.OPEN) this._socket.send(framed.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
private onSocketOpened(): void
|
private onSocketOpened(): void
|
||||||
{
|
{
|
||||||
if(this._isReconnecting)
|
if(this._isReconnecting)
|
||||||
@@ -270,7 +409,23 @@ export class SocketConnection implements IConnection
|
|||||||
{
|
{
|
||||||
if(!this._socket || this._socket.readyState !== WebSocket.OPEN) return;
|
if(!this._socket || this._socket.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
this._socket.send(buffer);
|
if(this._cryptoState === 'disabled')
|
||||||
|
{
|
||||||
|
this._socket.send(buffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this._cryptoState === 'ready')
|
||||||
|
{
|
||||||
|
this.encryptAndSend(buffer).catch(err => NitroLogger.error('[ws-crypto] encrypt failed', err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this._cryptoState === 'awaiting_server_hello')
|
||||||
|
{
|
||||||
|
this._pendingEncryptedSends.push(buffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public processReceivedData(): void
|
public processReceivedData(): void
|
||||||
@@ -354,7 +509,6 @@ export class SocketConnection implements IConnection
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
//@ts-ignore
|
|
||||||
const parser = new events[0].parserClass();
|
const parser = new events[0].parserClass();
|
||||||
|
|
||||||
if(!parser || !parser.flush() || !parser.parse(wrapper)) return null;
|
if(!parser || !parser.flush() || !parser.parse(wrapper)) return null;
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
export const HANDSHAKE_MAGIC = 0xC0DEC0DE | 0;
|
||||||
|
export const TYPE_SERVER_HELLO = 0x01;
|
||||||
|
export const TYPE_CLIENT_HELLO = 0x02;
|
||||||
|
export const HKDF_INFO = 'nitro-ws-v1';
|
||||||
|
export const AES_KEY_BITS = 256;
|
||||||
|
export const NONCE_LEN = 12;
|
||||||
|
export const GCM_TAG_LEN = 16;
|
||||||
|
|
||||||
|
export async function generateEphemeralKeyPair(): Promise<CryptoKeyPair>
|
||||||
|
{
|
||||||
|
return window.crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [ 'deriveBits' ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportPublicKeySpki(publicKey: CryptoKey): Promise<ArrayBuffer>
|
||||||
|
{
|
||||||
|
return window.crypto.subtle.exportKey('spki', publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importPublicKeySpki(spki: ArrayBuffer): Promise<CryptoKey>
|
||||||
|
{
|
||||||
|
return window.crypto.subtle.importKey('spki', spki, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveSharedSecret(ourPrivate: CryptoKey, theirPublic: CryptoKey): Promise<ArrayBuffer>
|
||||||
|
{
|
||||||
|
return window.crypto.subtle.deriveBits({ name: 'ECDH', public: theirPublic }, ourPrivate, 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveAesKey(sharedSecret: ArrayBuffer): Promise<CryptoKey>
|
||||||
|
{
|
||||||
|
const ikm = await window.crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, [ 'deriveKey' ]);
|
||||||
|
return window.crypto.subtle.deriveKey(
|
||||||
|
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: new TextEncoder().encode(HKDF_INFO) },
|
||||||
|
ikm,
|
||||||
|
{ name: 'AES-GCM', length: AES_KEY_BITS },
|
||||||
|
false,
|
||||||
|
[ 'encrypt', 'decrypt' ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aesGcmEncrypt(key: CryptoKey, nonce: Uint8Array, plaintext: ArrayBuffer): Promise<ArrayBuffer>
|
||||||
|
{
|
||||||
|
return window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LEN * 8 }, key, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aesGcmDecrypt(key: CryptoKey, nonce: Uint8Array, ciphertextWithTag: ArrayBuffer): Promise<ArrayBuffer>
|
||||||
|
{
|
||||||
|
return window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LEN * 8 }, key, ciphertextWithTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomNonce(): Uint8Array
|
||||||
|
{
|
||||||
|
const n = new Uint8Array(NONCE_LEN);
|
||||||
|
window.crypto.getRandomValues(n);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClientHello(pubkeySpki: ArrayBuffer): ArrayBuffer
|
||||||
|
{
|
||||||
|
const out = new Uint8Array(4 + 1 + 2 + pubkeySpki.byteLength);
|
||||||
|
const dv = new DataView(out.buffer);
|
||||||
|
dv.setUint32(0, HANDSHAKE_MAGIC, false);
|
||||||
|
out[4] = TYPE_CLIENT_HELLO;
|
||||||
|
dv.setUint16(5, pubkeySpki.byteLength, false);
|
||||||
|
out.set(new Uint8Array(pubkeySpki), 7);
|
||||||
|
return out.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedServerHello
|
||||||
|
{
|
||||||
|
pubkeySpki: ArrayBuffer;
|
||||||
|
signature: ArrayBuffer | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseServerHello(frame: ArrayBuffer): ParsedServerHello
|
||||||
|
{
|
||||||
|
if (frame.byteLength < 7) throw new Error('server_hello frame too short');
|
||||||
|
const dv = new DataView(frame);
|
||||||
|
const magic = dv.getUint32(0, false);
|
||||||
|
if (magic >>> 0 !== (HANDSHAKE_MAGIC >>> 0)) throw new Error('server_hello magic mismatch');
|
||||||
|
const type = dv.getUint8(4);
|
||||||
|
if (type !== TYPE_SERVER_HELLO) throw new Error(`expected server_hello, got type=0x${ type.toString(16) }`);
|
||||||
|
|
||||||
|
const keyLen = dv.getUint16(5, false);
|
||||||
|
if (keyLen <= 0 || keyLen > frame.byteLength - 7) throw new Error(`invalid server key length ${ keyLen }`);
|
||||||
|
const pubkeySpki = frame.slice(7, 7 + keyLen);
|
||||||
|
|
||||||
|
const remaining = frame.byteLength - (7 + keyLen);
|
||||||
|
if (remaining === 0) return { pubkeySpki, signature: null };
|
||||||
|
if (remaining < 2) throw new Error('truncated signature trailer');
|
||||||
|
const sigLen = dv.getUint16(7 + keyLen, false);
|
||||||
|
if (sigLen <= 0 || 7 + keyLen + 2 + sigLen !== frame.byteLength) throw new Error(`invalid signature length ${ sigLen }`);
|
||||||
|
const signature = frame.slice(7 + keyLen + 2, 7 + keyLen + 2 + sigLen);
|
||||||
|
return { pubkeySpki, signature };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importSigningPublicKeyFromBase64(spkiBase64: string): Promise<CryptoKey>
|
||||||
|
{
|
||||||
|
const bin = atob(spkiBase64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
return window.crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
bytes.buffer,
|
||||||
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||||
|
false,
|
||||||
|
[ 'verify' ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyEphemeralSignature(signingKey: CryptoKey, signature: ArrayBuffer, signedBytes: ArrayBuffer): Promise<boolean>
|
||||||
|
{
|
||||||
|
return window.crypto.subtle.verify(
|
||||||
|
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
signingKey,
|
||||||
|
signature,
|
||||||
|
signedBytes
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './WsSessionCrypto';
|
||||||
@@ -4,6 +4,7 @@ export * from './NitroMessages';
|
|||||||
export * from './SocketConnection';
|
export * from './SocketConnection';
|
||||||
export * from './codec';
|
export * from './codec';
|
||||||
export * from './codec/evawire';
|
export * from './codec/evawire';
|
||||||
|
export * from './crypto';
|
||||||
export * from './messages';
|
export * from './messages';
|
||||||
export * from './messages/incoming';
|
export * from './messages/incoming';
|
||||||
export * from './messages/incoming/advertisement';
|
export * from './messages/incoming/advertisement';
|
||||||
|
|||||||
@@ -378,49 +378,20 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas
|
|||||||
|
|
||||||
let leftIdx = 0;
|
let leftIdx = 0;
|
||||||
let rightIdx = 0;
|
let rightIdx = 0;
|
||||||
|
let maxY = hull[0].y;
|
||||||
|
|
||||||
for(let i = 1; i < hull.length; i++)
|
for(let i = 1; i < hull.length; i++)
|
||||||
{
|
{
|
||||||
if(hull[i].x < hull[leftIdx].x) leftIdx = i;
|
if(hull[i].x < hull[leftIdx].x) leftIdx = i;
|
||||||
if(hull[i].x > hull[rightIdx].x) rightIdx = i;
|
if(hull[i].x > hull[rightIdx].x) rightIdx = i;
|
||||||
|
if(hull[i].y > maxY) maxY = hull[i].y;
|
||||||
}
|
}
|
||||||
|
|
||||||
const n = hull.length;
|
|
||||||
|
|
||||||
// Collect arc going CCW: leftIdx → rightIdx via increasing indices
|
|
||||||
const arcCCW: { x: number; y: number }[] = [];
|
|
||||||
let idx = leftIdx;
|
|
||||||
|
|
||||||
while(idx !== rightIdx)
|
|
||||||
{
|
|
||||||
arcCCW.push(hull[idx]);
|
|
||||||
idx = (idx + 1) % n;
|
|
||||||
}
|
|
||||||
|
|
||||||
arcCCW.push(hull[rightIdx]);
|
|
||||||
|
|
||||||
// Collect arc going CW: leftIdx → rightIdx via decreasing indices
|
|
||||||
const arcCW: { x: number; y: number }[] = [];
|
|
||||||
idx = leftIdx;
|
|
||||||
|
|
||||||
while(idx !== rightIdx)
|
|
||||||
{
|
|
||||||
arcCW.push(hull[idx]);
|
|
||||||
idx = (idx - 1 + n) % n;
|
|
||||||
}
|
|
||||||
|
|
||||||
arcCW.push(hull[rightIdx]);
|
|
||||||
|
|
||||||
// Bottom arc = the arc with larger average Y (floor/front tiles)
|
|
||||||
const avgCCW = arcCCW.reduce((s, p) => s + p.y, 0) / arcCCW.length;
|
|
||||||
const avgCW = arcCW.reduce((s, p) => s + p.y, 0) / arcCW.length;
|
|
||||||
const bottomArc = avgCCW >= avgCW ? arcCCW : arcCW;
|
|
||||||
|
|
||||||
// Build polygon: extend upward far above walls, then trace bottom boundary
|
|
||||||
return [
|
return [
|
||||||
{ x: hull[leftIdx].x, y: -extension },
|
{ x: hull[leftIdx].x - extension, y: -extension },
|
||||||
{ x: hull[rightIdx].x, y: -extension },
|
{ x: hull[rightIdx].x + extension, y: -extension },
|
||||||
...bottomArc.slice().reverse()
|
{ x: hull[rightIdx].x + extension, y: maxY + extension },
|
||||||
|
{ x: hull[leftIdx].x - extension, y: maxY + extension }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user