mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
feat: use character identity in single player
This commit is contained in:
@@ -71,6 +71,12 @@
|
||||
<script src="js/display/renderEngine.js"></script>
|
||||
<script src="js/core/gameState.js"></script>
|
||||
<script src="js/core/game.js"></script>
|
||||
<script src="js/render/atlas.js"></script>
|
||||
<script src="js/render/skins.js"></script>
|
||||
<script src="js/render/charSprites.js"></script>
|
||||
<script src="js/render/assetPacks.js"></script>
|
||||
<script src="js/render/charCustomize.js"></script>
|
||||
<script src="js/core/singlePlayerAvatar.js"></script>
|
||||
|
||||
<!-- Other modules -->
|
||||
<script src="js/ui/ui.js"></script>
|
||||
@@ -111,6 +117,9 @@
|
||||
<button id="tavernButton" style="background: #2a0a3a; color: #d8b4fe; border: 2px solid #a855f7; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;" onclick="window.location.href='/tavern'">
|
||||
🍺 <span class="lbl-full">TAVERN</span><span class="lbl-short">TAVERN</span>
|
||||
</button>
|
||||
<button id="characterButton" style="background: #163044; color: #a7d8ff; border: 2px solid #5aa7d8; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px;">
|
||||
🧍 <span id="characterButtonLabel" class="lbl-full">CHARACTER</span><span class="lbl-short">CHAR</span>
|
||||
</button>
|
||||
<button id="shopButton" style="background: #004400; color: #0f0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;">
|
||||
🛒 SHOP
|
||||
</button>
|
||||
@@ -356,6 +365,10 @@
|
||||
} else {
|
||||
console.error("Game object not found!");
|
||||
}
|
||||
|
||||
if (typeof SinglePlayerAvatar !== 'undefined') {
|
||||
SinglePlayerAvatar.init();
|
||||
}
|
||||
|
||||
// Initialize SocketHandlers (CRITICAL for receiving game_start events!)
|
||||
if (typeof SocketHandlers !== 'undefined') {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// SinglePlayerAvatar bridges the shared character identity/render kit into the legacy ROT.js
|
||||
// dungeon display. The dungeon still draws through ROT; this module owns a sprite overlay that
|
||||
// can be cleared safely back to the classic @ tile whenever assets or identity are unavailable.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
var DEFAULT_APPEARANCE = { avatar: 'default', tint: 'none', equipment: { body: 'none', head: 'none', shield: 'none', weapon: 'none' } };
|
||||
|
||||
function normalize(input) {
|
||||
if (root.RK && root.RK.normalizeAppearance) return root.RK.normalizeAppearance(input || DEFAULT_APPEARANCE);
|
||||
return DEFAULT_APPEARANCE;
|
||||
}
|
||||
|
||||
function baseKind(appearance) {
|
||||
if (!appearance || !root.RK) return 'color';
|
||||
if (root.RK.isChar && root.RK.isChar(appearance.avatar)) return 'char';
|
||||
if (root.RK.isSkin && root.RK.isSkin(appearance.avatar)) return 'skin';
|
||||
return 'color';
|
||||
}
|
||||
|
||||
function requestGameRedraw() {
|
||||
if (root.Game && root.Game._gameActive && root.Game._drawGameScreen) {
|
||||
root.Game._drawGameScreen();
|
||||
}
|
||||
}
|
||||
|
||||
var SinglePlayerAvatar = {
|
||||
_appearance: null,
|
||||
_overlay: null,
|
||||
_ctx: null,
|
||||
_lastWorld: null,
|
||||
_facing: 'down',
|
||||
_loading: {},
|
||||
_initialized: false,
|
||||
|
||||
init: function () {
|
||||
if (this._initialized) return;
|
||||
this._initialized = true;
|
||||
var identity = root.RK && root.RK.loadIdentity ? root.RK.loadIdentity() : null;
|
||||
this.setAppearance(identity && identity.appearance ? identity.appearance : DEFAULT_APPEARANCE, { save: false, redraw: false });
|
||||
this._bindButton();
|
||||
this.updateButton();
|
||||
},
|
||||
|
||||
_bindButton: function () {
|
||||
var self = this;
|
||||
var btn = root.document && root.document.getElementById('characterButton');
|
||||
if (!btn || btn._rkBound) return;
|
||||
btn._rkBound = true;
|
||||
btn.addEventListener('click', function () {
|
||||
if (!root.RK || !root.RK.openCustomize) return;
|
||||
root.RK.openCustomize(self._appearance || DEFAULT_APPEARANCE, function (appearance) {
|
||||
var normalized = normalize(appearance);
|
||||
self.setAppearance(normalized, { save: true, redraw: true });
|
||||
if (root.socket && root.socket.emit) {
|
||||
root.socket.emit('identity:update', { appearance: normalized });
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
setAppearance: function (appearance, opts) {
|
||||
opts = opts || {};
|
||||
this._appearance = normalize(appearance);
|
||||
if (opts.save !== false && root.RK && root.RK.saveIdentity) root.RK.saveIdentity(this._appearance);
|
||||
this.updateButton();
|
||||
if (opts.redraw !== false) requestGameRedraw();
|
||||
},
|
||||
|
||||
applyIdentity: function (data) {
|
||||
if (!data) return;
|
||||
if (data.entitlements && root.RK && root.RK.setEntitlementSnapshot) {
|
||||
root.RK.setEntitlementSnapshot(data.entitlements);
|
||||
}
|
||||
if (data.appearance) this.setAppearance(data.appearance, { save: true, redraw: true });
|
||||
else this.updateButton();
|
||||
},
|
||||
|
||||
applyEntitlements: function (data) {
|
||||
if (data && root.RK && root.RK.setEntitlementSnapshot) {
|
||||
root.RK.setEntitlementSnapshot(data);
|
||||
this.updateButton();
|
||||
}
|
||||
},
|
||||
|
||||
updateButton: function () {
|
||||
var btn = root.document && root.document.getElementById('characterButton');
|
||||
var label = root.document && root.document.getElementById('characterButtonLabel');
|
||||
if (!btn || !label) return;
|
||||
if (!root.RK || !root.RK.appearance) {
|
||||
label.textContent = 'CHARACTER';
|
||||
return;
|
||||
}
|
||||
var info = root.RK.appearance(this._appearance || DEFAULT_APPEARANCE);
|
||||
label.textContent = (info && info.label ? info.label : 'CHARACTER').toUpperCase();
|
||||
btn.title = info && info.premium ? 'Premium character' : 'Character';
|
||||
},
|
||||
|
||||
_baseCanvas: function () {
|
||||
if (!root.DisplayManager || !root.DisplayManager.getDisplay()) return null;
|
||||
var display = root.DisplayManager.getDisplay();
|
||||
return display && display.getContainer ? display.getContainer() : null;
|
||||
},
|
||||
|
||||
_syncOverlay: function () {
|
||||
var base = this._baseCanvas();
|
||||
var host = root.document && root.document.getElementById('game-display');
|
||||
if (!base || !host) return null;
|
||||
|
||||
if (!this._overlay) {
|
||||
this._overlay = root.document.createElement('canvas');
|
||||
this._overlay.className = 'single-player-avatar-overlay';
|
||||
this._overlay.style.position = 'absolute';
|
||||
this._overlay.style.pointerEvents = 'none';
|
||||
this._overlay.style.zIndex = '6';
|
||||
this._overlay.style.imageRendering = 'pixelated';
|
||||
host.appendChild(this._overlay);
|
||||
this._ctx = this._overlay.getContext('2d');
|
||||
}
|
||||
|
||||
if (this._overlay.width !== base.width) this._overlay.width = base.width;
|
||||
if (this._overlay.height !== base.height) this._overlay.height = base.height;
|
||||
this._overlay.style.left = base.offsetLeft + 'px';
|
||||
this._overlay.style.top = base.offsetTop + 'px';
|
||||
this._overlay.style.width = base.offsetWidth + 'px';
|
||||
this._overlay.style.height = base.offsetHeight + 'px';
|
||||
this._overlay.style.display = 'block';
|
||||
return this._ctx;
|
||||
},
|
||||
|
||||
clearOverlay: function () {
|
||||
if (this._ctx && this._overlay) this._ctx.clearRect(0, 0, this._overlay.width, this._overlay.height);
|
||||
},
|
||||
|
||||
currentAppearance: function () {
|
||||
return this._appearance || normalize(DEFAULT_APPEARANCE);
|
||||
},
|
||||
|
||||
_ensureAssets: function (appearance) {
|
||||
var kind = baseKind(appearance);
|
||||
var self = this;
|
||||
if (kind === 'char') {
|
||||
if (root.RK.charAtlas && root.RK.charAtlas()) return true;
|
||||
if (root.RK.loadCharAtlas && !this._loading.char) {
|
||||
this._loading.char = true;
|
||||
root.RK.loadCharAtlas(function () {
|
||||
self._loading.char = false;
|
||||
requestGameRedraw();
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (kind === 'skin') {
|
||||
if (root.RK.skinSheet && root.RK.skinSheet(appearance.avatar)) return true;
|
||||
if (root.RK.loadSkin && !this._loading[appearance.avatar]) {
|
||||
this._loading[appearance.avatar] = true;
|
||||
var rec = root.RK.loadSkin(appearance.avatar);
|
||||
if (rec && rec.cbs) rec.cbs.push(function () {
|
||||
self._loading[appearance.avatar] = false;
|
||||
requestGameRedraw();
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
canDrawPlayer: function () {
|
||||
if (!root.RK) return false;
|
||||
var appearance = this.currentAppearance();
|
||||
var kind = baseKind(appearance);
|
||||
if (kind !== 'char' && kind !== 'skin') return false;
|
||||
return this._ensureAssets(appearance);
|
||||
},
|
||||
|
||||
_updateFacing: function (player) {
|
||||
if (!player) return this._facing;
|
||||
if (this._lastWorld) {
|
||||
var dx = player.x - this._lastWorld.x;
|
||||
var dy = player.y - this._lastWorld.y;
|
||||
if (dx < 0) this._facing = 'left';
|
||||
else if (dx > 0) this._facing = 'right';
|
||||
else if (dy < 0) this._facing = 'up';
|
||||
else if (dy > 0) this._facing = 'down';
|
||||
}
|
||||
this._lastWorld = { x: player.x, y: player.y };
|
||||
return this._facing;
|
||||
},
|
||||
|
||||
drawPlayer: function (gameState, viewport) {
|
||||
if (!gameState || !gameState.player || !viewport || !this.canDrawPlayer()) {
|
||||
this.clearOverlay();
|
||||
return false;
|
||||
}
|
||||
var ctx = this._syncOverlay();
|
||||
if (!ctx) return false;
|
||||
|
||||
this.clearOverlay();
|
||||
var appearance = this.currentAppearance();
|
||||
var kind = baseKind(appearance);
|
||||
var player = gameState.player;
|
||||
var facing = this._updateFacing(player);
|
||||
var cell = viewport.cell || (root.options && root.options.tileWidth) || 32;
|
||||
var now = Date.now();
|
||||
|
||||
if (kind === 'char') return this._drawChar(ctx, appearance, player, viewport.screenX, viewport.screenY, cell, facing, now);
|
||||
if (kind === 'skin') return this._drawSkin(ctx, appearance, player, viewport.screenX, viewport.screenY, cell, facing, now);
|
||||
return false;
|
||||
},
|
||||
|
||||
_drawChar: function (ctx, appearance, player, sx, sy, cell, facing, now) {
|
||||
var ch = root.RK.CHARS && root.RK.CHARS[appearance.avatar];
|
||||
if (!ch || !root.RK.charFrame || !root.RK.drawCharTileCanvas) return false;
|
||||
var e = { id: 'single-player', x: player.x, y: player.y, facing: facing };
|
||||
var frame = root.RK.charFrame(ch, e, now);
|
||||
var dw = cell * 1.8;
|
||||
var dh = dw * frame.squash;
|
||||
var cx = sx * cell + cell / 2;
|
||||
var dy = (sy * cell + cell) - dh + frame.bob;
|
||||
var ap = root.RK.charAppearance({ avatar: appearance.avatar, appearance: appearance });
|
||||
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
function drawComposite(dx, dy2) {
|
||||
root.RK.drawCharTileCanvas(ctx, ch.frame, ap.tint, dx, dy2, dw, dh, ap.colors, 'base');
|
||||
root.RK.charOverlayParts(ap).forEach(function (part) {
|
||||
root.RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy2, dw, dh, part.colorable ? ap.colors : null, part.slot);
|
||||
});
|
||||
}
|
||||
if (frame.flip) {
|
||||
ctx.save();
|
||||
ctx.translate(cx + dw / 2, dy);
|
||||
ctx.scale(-1, 1);
|
||||
drawComposite(0, 0);
|
||||
ctx.restore();
|
||||
} else {
|
||||
drawComposite(cx - dw / 2, dy);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
_drawSkin: function (ctx, appearance, player, sx, sy, cell, facing, now) {
|
||||
var rec = root.RK.skinSheet && root.RK.skinSheet(appearance.avatar);
|
||||
var skin = root.RK.SKINS && root.RK.SKINS[appearance.avatar];
|
||||
if (!rec || !skin || !root.RK.skinFrame) return false;
|
||||
var e = { id: 'single-player-skin', x: player.x, y: player.y, facing: facing };
|
||||
var fr = root.RK.skinFrame(skin, e, now);
|
||||
var dh = cell * (skin.scale || 1.7);
|
||||
var dw = dh * (skin.frameW / skin.frameH);
|
||||
var cx = sx * cell + cell / 2;
|
||||
var dx = cx - dw / 2;
|
||||
var dy = (sy * cell + cell) - dh;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(rec.img, fr.col * skin.frameW, fr.row * skin.frameH, skin.frameW, skin.frameH, dx, dy, dw, dh);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
root.SinglePlayerAvatar = SinglePlayerAvatar;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = SinglePlayerAvatar;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -115,6 +115,9 @@ var DisplayManager = {
|
||||
clearDisplay: function() {
|
||||
if (!this.ensureDisplay()) return;
|
||||
this.forceClearToBlack();
|
||||
if (window.SinglePlayerAvatar && window.SinglePlayerAvatar.clearOverlay) {
|
||||
window.SinglePlayerAvatar.clearOverlay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ var RenderEngine = {
|
||||
const playerWY = player.y;
|
||||
const centerX = Math.floor(this._screenWidth / 2);
|
||||
const centerY = Math.floor(this._screenHeight / 2);
|
||||
const avatarOverlayReady = !!(window.SinglePlayerAvatar &&
|
||||
window.SinglePlayerAvatar.canDrawPlayer &&
|
||||
window.SinglePlayerAvatar.canDrawPlayer(gameState));
|
||||
|
||||
const topLeftWX = playerWX - centerX;
|
||||
const topLeftWY = playerWY - centerY;
|
||||
@@ -222,10 +225,12 @@ var RenderEngine = {
|
||||
|
||||
// Render player (always on top)
|
||||
if (wx === playerWX && wy === playerWY) {
|
||||
charStack.push(playerTile);
|
||||
// Don't apply shadows to player - keep them clearly visible
|
||||
fgStack.push("transparent");
|
||||
bgStack.push("transparent");
|
||||
if (!avatarOverlayReady) {
|
||||
charStack.push(playerTile);
|
||||
// Don't apply shadows to player - keep them clearly visible
|
||||
fgStack.push("transparent");
|
||||
bgStack.push("transparent");
|
||||
}
|
||||
}
|
||||
|
||||
if (charStack.length > 0) {
|
||||
@@ -252,6 +257,14 @@ var RenderEngine = {
|
||||
}
|
||||
}
|
||||
|
||||
if (avatarOverlayReady && window.SinglePlayerAvatar.drawPlayer) {
|
||||
window.SinglePlayerAvatar.drawPlayer(gameState, {
|
||||
screenX: centerX,
|
||||
screenY: centerY,
|
||||
cell: window.options ? window.options.tileWidth : 32
|
||||
});
|
||||
}
|
||||
|
||||
// Render message at bottom if present
|
||||
if (message) {
|
||||
ScreenManager.drawCenteredText(this._screenHeight - 1, message);
|
||||
|
||||
@@ -133,17 +133,19 @@ const SocketHandlers = {
|
||||
socket.on('queue_cancelled', this.onQueueCancelled);
|
||||
|
||||
// Payment/Address handlers
|
||||
socket.on('address_detected', this.onAddressDetected);
|
||||
socket.on('address_confirmed', this.onAddressConfirmed);
|
||||
socket.on('address_update_error', this.onAddressUpdateError);
|
||||
socket.on('address_prompt', this.onAddressPrompt);
|
||||
socket.on('address_detected', this.onAddressDetected);
|
||||
socket.on('address_confirmed', this.onAddressConfirmed);
|
||||
socket.on('address_update_error', this.onAddressUpdateError);
|
||||
socket.on('address_prompt', this.onAddressPrompt);
|
||||
socket.on('game_mode_info', this.onGameModeInfo);
|
||||
socket.on('payment_created', this.onPaymentCreated);
|
||||
socket.on('payment_confirmed', this.onPaymentConfirmed);
|
||||
socket.on('payment_detected', this.onPaymentDetected);
|
||||
socket.on('show_payment_options', this.onShowPaymentOptions);
|
||||
socket.on('balance_critical', this.onBalanceCritical);
|
||||
socket.on('credits_update', this.onCreditsUpdate);
|
||||
socket.on('credits_update', this.onCreditsUpdate);
|
||||
socket.on('identity_update', this.onIdentityUpdate);
|
||||
socket.on('identity_error', this.onIdentityError);
|
||||
socket.on('user_count', this.onUserCount);
|
||||
|
||||
// Block height handler
|
||||
@@ -205,6 +207,7 @@ const SocketHandlers = {
|
||||
clientId: socket.id,
|
||||
userAgent: navigator.userAgent
|
||||
});
|
||||
socket.emit('identity:get');
|
||||
// Update small status banner immediately
|
||||
SocketHandlers._setBannerStatus('Connected', '#0f0');
|
||||
},
|
||||
@@ -228,6 +231,12 @@ const SocketHandlers = {
|
||||
if (data && typeof data.credits === 'number') {
|
||||
SocketHandlers._updateCreditsDisplay(data.credits);
|
||||
}
|
||||
if (typeof SinglePlayerAvatar !== 'undefined') {
|
||||
SinglePlayerAvatar.applyEntitlements({
|
||||
credits: data && data.credits,
|
||||
totalCreditsPurchased: data && data.totalCreditsPurchased
|
||||
});
|
||||
}
|
||||
if (data && data.payoutAddress) {
|
||||
$('#messages').append($('<li class="address-confirmed" style="color:#0f0;">').text('Payout address restored.'));
|
||||
UI.scrollChat();
|
||||
@@ -245,6 +254,27 @@ const SocketHandlers = {
|
||||
if (typeof data.balance === 'number') {
|
||||
SocketHandlers._updateCreditsDisplay(data.balance);
|
||||
}
|
||||
if (typeof SinglePlayerAvatar !== 'undefined') {
|
||||
SinglePlayerAvatar.applyEntitlements({
|
||||
credits: data.balance,
|
||||
balance: data.balance,
|
||||
totalCreditsPurchased: data.totalCreditsPurchased,
|
||||
total_credits_purchased: data.total_credits_purchased
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onIdentityUpdate: function(data) {
|
||||
if (typeof SinglePlayerAvatar !== 'undefined') {
|
||||
SinglePlayerAvatar.applyIdentity(data);
|
||||
}
|
||||
},
|
||||
|
||||
onIdentityError: function(data) {
|
||||
if (data && data.message) {
|
||||
$('#messages').append($('<li class="error" style="color:#f66;">').text(data.message));
|
||||
UI.scrollChat();
|
||||
}
|
||||
},
|
||||
|
||||
// Default credits per game (overridden by game_mode_info)
|
||||
@@ -682,6 +712,9 @@ const SocketHandlers = {
|
||||
SocketHandlers._cryptoType = data.cryptoType || 'WOW';
|
||||
SocketHandlers._currencyLabel = data.currencyLabel || data.cryptoType || 'WOW'; // sXMR on stagenet
|
||||
SocketHandlers._explorerTxUrl = data.explorerTxUrl || null;
|
||||
if (data.entitlements && typeof SinglePlayerAvatar !== 'undefined') {
|
||||
SinglePlayerAvatar.applyEntitlements(data.entitlements);
|
||||
}
|
||||
|
||||
// Mode availability (Solo / Tavern / Multiplayer). Backward compatible: if the server
|
||||
// doesn't send `modes`, assume solo-only (the historical behavior).
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('single-player character identity wiring', () => {
|
||||
const indexHtml = fs.readFileSync(path.join(__dirname, '../html/index.html'), 'utf8');
|
||||
const socketHandlers = fs.readFileSync(path.join(__dirname, '../html/js/network/socketHandlers.js'), 'utf8');
|
||||
const renderEngine = fs.readFileSync(path.join(__dirname, '../html/js/display/renderEngine.js'), 'utf8');
|
||||
|
||||
test('main page loads shared character renderer dependencies before the overlay bridge', () => {
|
||||
const order = [
|
||||
'js/render/atlas.js',
|
||||
'js/render/skins.js',
|
||||
'js/render/charSprites.js',
|
||||
'js/render/assetPacks.js',
|
||||
'js/render/charCustomize.js',
|
||||
'js/core/singlePlayerAvatar.js'
|
||||
].map(src => indexHtml.indexOf(src));
|
||||
|
||||
expect(order.every(pos => pos > -1)).toBe(true);
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b));
|
||||
expect(indexHtml).toContain('id="characterButton"');
|
||||
});
|
||||
|
||||
test('socket identity updates are forwarded to the single-player avatar bridge', () => {
|
||||
expect(socketHandlers).toContain("socket.on('identity_update', this.onIdentityUpdate)");
|
||||
expect(socketHandlers).toContain("socket.emit('identity:get')");
|
||||
expect(socketHandlers).toContain('SinglePlayerAvatar.applyIdentity(data)');
|
||||
});
|
||||
|
||||
test('dungeon renderer suppresses the legacy player tile only when overlay is ready', () => {
|
||||
expect(renderEngine).toContain('SinglePlayerAvatar.canDrawPlayer');
|
||||
expect(renderEngine).toContain('if (!avatarOverlayReady)');
|
||||
expect(renderEngine).toContain('SinglePlayerAvatar.drawPlayer');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user