feat: calendrier impérial et conversions calendaires

- calendrier impérial : fenêtre compacte (260px), affichage date
- commande /cal, auto-ouverture paramétrable dans les settings
- socket calendarSync : mise à jour temps réel pour les joueurs
- config MJ : année, jour (label live), heure:minute, planète
- boutons +1 jour et +1 heure (report 24h→jour, 365→1)
- conversions : Solomani, Vilani, Zhodani (Olympiade), Aslan, K'Kree, Hiver
- formules wiki.travellerrpg.com/Date_Conversion, section repliable
- fix: renderChatInput /cal trop large → exact match
- fix: fenêtre refermable (onClose nullifie la référence)
- fix: socket joueur lit data direct au lieu de readCalendar()
This commit is contained in:
2026-07-23 23:28:49 +02:00
parent 5a4d4c45dc
commit 00bcc775fe
9 changed files with 723 additions and 2 deletions
+2 -1
View File
@@ -21,7 +21,8 @@
"scripts/TravellerNpcDialog.js", "scripts/TravellerNpcDialog.js",
"scripts/mgt2eMigration.js", "scripts/mgt2eMigration.js",
"scripts/npcRollTableSync.js", "scripts/npcRollTableSync.js",
"scripts/mgt2eSkills.js" "scripts/mgt2eSkills.js",
"scripts/imperialCalendar.js"
], ],
"styles": [ "styles": [
"styles/commerce.css", "styles/commerce.css",
+62
View File
@@ -0,0 +1,62 @@
import { formatImperialDate } from './calendarDate.js';
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
export class CalendarApp extends HandlebarsApplicationMixin(ApplicationV2) {
static DEFAULT_OPTIONS = {
id: 'mgt2-calendar',
classes: ['mgt2-calendar'],
position: { width: 260, height: 'auto' },
window: {
icon: 'fas fa-calendar-alt',
title: 'Calendrier Impérial',
resizable: false,
},
};
static PARTS = {
main: {
template: `modules/${MODULE_ID}/templates/calendar-app.hbs`,
root: true,
},
};
constructor({ onConfig, onClose } = {}) {
super();
this._onConfig = onConfig;
this._onClose = onClose;
this._data = { year: 1116, day: 1, hour: 12, minute: 0, planet: '' };
}
get title() {
return 'Calendrier Impérial';
}
async _prepareContext() {
return {
dateLabel: formatImperialDate(this._data),
isGM: game.user?.isGM ?? false,
};
}
async _onRender(context, options) {
await super._onRender(context, options);
const html = $(this.element);
html.find('.mgt2-cal-config-btn').on('click', () => {
this._onConfig?.();
});
}
updateDisplay(data) {
this._data = { ...this._data, ...data };
this.render();
}
close() {
this._onConfig = null;
this._onClose?.();
this._onClose = null;
return super.close();
}
}
+116
View File
@@ -0,0 +1,116 @@
import { getDayLabel, computeConversions } from './calendarDate.js';
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
export class CalendarConfigDialog extends HandlebarsApplicationMixin(ApplicationV2) {
static DEFAULT_OPTIONS = {
id: 'mgt2-calendar-config',
classes: ['mgt2-calendar'],
position: { width: 300, height: 'auto' },
window: {
icon: 'fas fa-cog',
title: 'Configurer le calendrier',
resizable: false,
},
};
static PARTS = {
main: {
template: `modules/${MODULE_ID}/templates/calendar-config.hbs`,
root: true,
},
};
constructor({ data, onApply } = {}) {
super();
this._data = { ...data };
this._onApply = onApply;
}
get title() {
return 'Configurer le calendrier';
}
async _prepareContext() {
return {
year: this._data.year,
day: this._data.day,
hour: this._data.hour,
minute: this._data.minute,
planet: this._data.planet ?? '',
dayLabel: getDayLabel(this._data.day),
conversions: computeConversions(this._data.year),
};
}
async _onRender(context, options) {
await super._onRender(context, options);
const html = $(this.element);
html.find('[name="day"]').on('input', () => {
const day = parseInt(html.find('[name="day"]').val(), 10);
if (day >= 1 && day <= 365) {
html.find('.mgt2-cal-day-label').text(getDayLabel(day));
}
});
html.find('[name="year"]').on('input', () => {
const year = parseInt(html.find('[name="year"]').val(), 10) || 0;
const conv = computeConversions(year);
html.find('.mgt2-cal-conv-val[data-cal="solomani"]').text(conv.solomani);
html.find('.mgt2-cal-conv-val[data-cal="vilani"]').text(conv.vilani);
html.find('.mgt2-cal-conv-val[data-cal="zhodani"]').text(conv.zhodani);
html.find('.mgt2-cal-conv-val[data-cal="aslan"]').text(conv.aslan);
html.find('.mgt2-cal-conv-val[data-cal="kkree"]').text(conv.kkree);
html.find('.mgt2-cal-conv-val[data-cal="hiver"]').text(conv.hiver);
});
html.find('[data-action="day-inc"]').on('click', () => {
const input = html.find('[name="day"]');
let day = parseInt(input.val(), 10) || 1;
day = day >= 365 ? 1 : day + 1;
input.val(day);
html.find('.mgt2-cal-day-label').text(getDayLabel(day));
});
html.find('[data-action="hour-inc"]').on('click', () => {
const dayInput = html.find('[name="day"]');
const hourInput = html.find('[name="hour"]');
const minuteInput = html.find('[name="minute"]');
let day = parseInt(dayInput.val(), 10) || 1;
let hour = parseInt(hourInput.val(), 10) || 0;
let minute = parseInt(minuteInput.val(), 10) || 0;
hour += 1;
if (hour >= 24) {
hour = 0;
day = day >= 365 ? 1 : day + 1;
}
hourInput.val(hour);
dayInput.val(day);
html.find('.mgt2-cal-day-label').text(getDayLabel(day));
});
html.find('[data-action="apply"]').on('click', () => {
this._readAndApply(html);
});
}
_readAndApply(html) {
const year = parseInt(html.find('[name="year"]').val(), 10) || 1116;
const day = Math.min(365, Math.max(1, parseInt(html.find('[name="day"]').val(), 10) || 1));
const hour = Math.min(23, Math.max(0, parseInt(html.find('[name="hour"]').val(), 10) || 0));
const minute = Math.min(59, Math.max(0, parseInt(html.find('[name="minute"]').val(), 10) || 0));
const planet = html.find('[name="planet"]').val()?.trim() ?? '';
const data = { year, day, hour, minute, planet };
this._onApply?.(data);
}
close() {
this._onApply = null;
return super.close();
}
}
+95
View File
@@ -0,0 +1,95 @@
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
export function imperialToSolomani(impYear) {
return Math.round(impYear * 1.0006644 + 4521);
}
export function imperialToVilani(impYear) {
return Math.round((impYear + 4045) / 1.312);
}
export function imperialToZhodani(impYear) {
const raw = (impYear + 6731) / 2.26;
const olympiad = Math.floor(raw);
const frac = raw - olympiad;
const year = Math.min(3, Math.max(1, Math.round(frac * 3)));
return { olympiad, year, label: `${olympiad}.${year}` };
}
export function imperialToAslan(impYear) {
return Math.round((impYear + 2083) / 0.877);
}
export function imperialToKkree(impYear) {
return Math.round((impYear + 8145) / 1.2);
}
export function imperialToHiver(impYear) {
return Math.round((impYear - 410) * 2);
}
export function computeConversions(impYear) {
const y = impYear ?? 1116;
const z = imperialToZhodani(y);
return {
solomani: `${imperialToSolomani(y)} AD`,
vilani: `${imperialToVilani(y)} VI`,
zhodani: z.label,
aslan: `${imperialToAslan(y)}`,
kkree: `${imperialToKkree(y)}`,
hiver: `${imperialToHiver(y)} HF`,
};
}
export const DAY_NAMES = [
'Holiday',
'Wonday',
'Tuday',
'Thirday',
'Forday',
'Fiday',
'Sixday',
'Senday',
];
export const DAY_LABELS_FR = {
Holiday: 'Holiday',
Wonday: 'Wonday',
Tuday: 'Tuday',
Thirday: 'Thirday',
Forday: 'Forday',
Fiday: 'Fiday',
Sixday: 'Sixday',
Senday: 'Senday',
};
export function getDayLabel(day) {
if (day < 1 || day > 365) return '?';
if (day === 1) return DAY_NAMES[0];
return DAY_NAMES[((day - 2) % 7) + 1];
}
export function getWeekday(day) {
if (day < 1 || day > 365) return -1;
if (day === 1) return -1;
return (day - 2) % 7;
}
export function formatImperialDate({ year, day, hour, minute, planet } = {}) {
const y = year ?? 1116;
const d = day ?? 1;
const dayLabel = getDayLabel(d);
const dayStr = String(d).padStart(3, '0');
let label = `${dayLabel}, ${dayStr}-${y} IC`;
if (hour != null && minute != null) {
const h = String(Math.round(hour)).padStart(2, '0');
const m = String(Math.round(minute)).padStart(2, '0');
label += `${h}:${m}`;
if (planet) label += ` (${planet})`;
}
return label;
}
export function getDefaultCalendar() {
return { year: 1116, day: 1, hour: 12, minute: 0, planet: '' };
}
+208
View File
@@ -0,0 +1,208 @@
import { CalendarApp } from './CalendarApp.js';
import { CalendarConfigDialog } from './CalendarConfigDialog.js';
import { getDefaultCalendar } from './calendarDate.js';
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
const ChatLogV2 = foundry.applications.sidebar.tabs.ChatLog;
let _calendarApp = null;
function readCalendar() {
return {
year: game.settings.get(MODULE_ID, 'calendarYear'),
day: game.settings.get(MODULE_ID, 'calendarDay'),
hour: game.settings.get(MODULE_ID, 'calendarHour'),
minute: game.settings.get(MODULE_ID, 'calendarMinute'),
planet: game.settings.get(MODULE_ID, 'calendarPlanet'),
};
}
async function writeCalendar(data) {
await game.settings.set(MODULE_ID, 'calendarYear', data.year);
await game.settings.set(MODULE_ID, 'calendarDay', data.day);
await game.settings.set(MODULE_ID, 'calendarHour', data.hour);
await game.settings.set(MODULE_ID, 'calendarMinute', data.minute);
await game.settings.set(MODULE_ID, 'calendarPlanet', data.planet);
}
function applyCalendar(data) {
if (_calendarApp) {
_calendarApp.updateDisplay(data);
}
}
async function broadcastCalendar(data) {
if (!game.socket?.emit) return;
game.socket.emit(`module.${MODULE_ID}`, {
_type: 'calendarSync',
...data,
});
}
function openCalendarApp() {
if (_calendarApp) {
_calendarApp.bringToTop();
return;
}
const data = readCalendar();
_calendarApp = new CalendarApp({
onConfig: () => openCalendarConfig(),
onClose: () => { _calendarApp = null; },
});
_calendarApp.updateDisplay(data);
_calendarApp.render({ force: true });
}
function openCalendarConfig() {
const data = readCalendar();
const dialog = new CalendarConfigDialog({
data,
onApply: async (newData) => {
await writeCalendar(newData);
applyCalendar(newData);
broadcastCalendar(newData);
dialog.close();
},
});
dialog.render({ force: true });
}
function registerCalCommand() {
if (!ChatLogV2?.CHAT_COMMANDS) return;
ChatLogV2.CHAT_COMMANDS.cal = {
rgx: /^\/cal(?:\s+(.*))?$/i,
fn: () => {
openCalendarApp();
return false;
},
};
console.log(`${MODULE_ID} | Commande /cal enregistrée via ChatLog.CHAT_COMMANDS`);
}
Hooks.once('init', () => {
const def = getDefaultCalendar();
game.settings.register(MODULE_ID, 'calendarYear', {
name: 'Année impériale',
scope: 'world',
config: false,
type: Number,
default: def.year,
});
game.settings.register(MODULE_ID, 'calendarDay', {
name: 'Jour impérial (1-365)',
scope: 'world',
config: false,
type: Number,
default: def.day,
});
game.settings.register(MODULE_ID, 'calendarHour', {
name: 'Heure locale',
scope: 'world',
config: false,
type: Number,
default: def.hour,
});
game.settings.register(MODULE_ID, 'calendarMinute', {
name: 'Minute locale',
scope: 'world',
config: false,
type: Number,
default: def.minute,
});
game.settings.register(MODULE_ID, 'calendarPlanet', {
name: 'Planète',
scope: 'world',
config: false,
type: String,
default: def.planet,
});
game.settings.register(MODULE_ID, 'calendarAutoOpen', {
name: 'Calendrier impérial : auto-ouverture',
hint: 'Ouvre automatiquement la fenêtre du calendrier au démarrage',
scope: 'world',
config: true,
type: Boolean,
default: true,
});
const loadTemplatesFn = foundry.applications?.handlebars?.loadTemplates || loadTemplates;
if (loadTemplatesFn) {
loadTemplatesFn([
`modules/${MODULE_ID}/templates/calendar-app.hbs`,
`modules/${MODULE_ID}/templates/calendar-config.hbs`,
]);
}
registerCalCommand();
console.log(`${MODULE_ID} | Calendrier impérial initialisé`);
});
Hooks.once('ready', () => {
game.socket.on(`module.${MODULE_ID}`, (data) => {
if (game.user?.isGM) return;
if (data?._type !== 'calendarSync') return;
if (!_calendarApp) {
const { year, day, hour, minute, planet } = data;
_calendarApp = new CalendarApp({
onClose: () => { _calendarApp = null; },
});
_calendarApp.updateDisplay({ year, day, hour, minute, planet });
_calendarApp.render({ force: true });
return;
}
_calendarApp.updateDisplay(data);
});
if (game.settings.get(MODULE_ID, 'calendarAutoOpen')) {
openCalendarApp();
}
});
Hooks.on('preCreateChatMessage', (message, data, options) => {
const content = message.content?.trim()?.toLowerCase();
if (content === '/cal' || content?.startsWith('/cal ')) {
openCalendarApp();
return false;
}
});
Hooks.on('chatMessage', (...args) => {
let message;
if (args[0]?.content !== undefined) {
message = args[0].content;
} else if (typeof args[1] === 'string') {
message = args[1];
} else {
return;
}
const trimmed = message?.trim()?.toLowerCase();
if (trimmed === '/cal' || trimmed?.startsWith('/cal ')) {
openCalendarApp();
return false;
}
});
Hooks.on('renderChatInput', (app, html, data) => {
let input;
if (html?.element) {
input = $(html.element).find('textarea[name="content"]');
} else if (html?.find) {
input = html.find('textarea[name="content"]');
} else {
input = $(html).find('textarea[name="content"]');
}
if (input.data('mgt2-cal-listener')) return;
input.data('mgt2-cal-listener', true);
input.on('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
const content = input.val()?.trim();
if (content === '/cal' || content?.startsWith('/cal ')) {
event.preventDefault();
event.stopImmediatePropagation();
openCalendarApp();
input.val('');
}
}
});
});
+1 -1
View File
@@ -12,7 +12,7 @@ function buildStartupNoticeHtml() {
<div style="padding:6px 8px;border-left:4px solid #c9a227;background:#f5f0e8;color:#2c2c3e;line-height:1.45;"> <div style="padding:6px 8px;border-left:4px solid #c9a227;background:#f5f0e8;color:#2c2c3e;line-height:1.45;">
<strong>MgT2e outils</strong> <strong>MgT2e outils</strong>
<span style="color:#6a5422;">·</span> <span style="color:#6a5422;">·</span>
<code>/commerce</code> <code>/pnj</code> <code>/rencontre</code> <code>/mission</code> <code>/sector</code> <code>/cal</code> <code>/commerce</code> <code>/pnj</code> <code>/rencontre</code> <code>/mission</code> <code>/sector</code>
<div style="margin-top:2px;font-size:0.92em;color:#6a5422;"> <div style="margin-top:2px;font-size:0.92em;color:#6a5422;">
par <a href="https://www.uberwald.me" target="_blank" rel="noopener noreferrer">LeRatierBretonnien / Uberwald</a> par <a href="https://www.uberwald.me" target="_blank" rel="noopener noreferrer">LeRatierBretonnien / Uberwald</a>
</div> </div>
+190
View File
@@ -1162,3 +1162,193 @@ a.mgt2-world-link:hover {
color: #555; color: #555;
margin-top: 4px; margin-top: 4px;
} }
/* === Calendrier Impérial =================================================== */
.mgt2-calendar .window-header {
background: linear-gradient(180deg, rgba(44, 44, 62, 0.96) 0%, rgba(30, 30, 43, 0.96) 100%);
border-bottom: 1px solid #c9a227;
}
.mgt2-calendar .window-title {
color: #d9b24c;
text-shadow: none;
}
.mgt2-calendar .window-content {
padding: 4px 8px;
background: #1a1a2e;
overflow: hidden;
}
.mgt2-cal-window {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 28px;
}
.mgt2-cal-display {
flex: 1;
}
.mgt2-cal-date {
color: #d8c79a;
font-size: 0.95em;
font-family: 'Roboto', sans-serif;
white-space: nowrap;
}
.mgt2-cal-config-btn {
flex: 0 0 auto;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
color: #8a7a5a;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85em;
border-radius: 3px;
transition: color 0.15s, background 0.15s;
}
.mgt2-cal-config-btn:hover {
color: #d9b24c;
background: rgba(201, 162, 39, 0.15);
}
/* Config dialog */
.mgt2-cal-config {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: #1a1a2e;
color: #d8c79a;
}
.mgt2-cal-config-row {
display: flex;
align-items: center;
gap: 8px;
}
.mgt2-cal-config-row label {
flex: 0 0 80px;
font-size: 0.85em;
color: #b8a87a;
}
.mgt2-cal-config-row input[type="number"],
.mgt2-cal-config-row input[type="text"] {
flex: 1;
padding: 3px 6px;
border: 1px solid #3a3a4e;
border-radius: 3px;
background: #252540;
color: #d8c79a;
font-size: 0.85em;
}
.mgt2-cal-config-row input:focus {
outline: none;
border-color: #c9a227;
}
.mgt2-cal-day-group {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
}
.mgt2-cal-day-group input {
flex: 0 0 60px;
}
.mgt2-cal-day-label {
font-size: 0.85em;
color: #d9b24c;
white-space: nowrap;
}
.mgt2-cal-config-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
margin-top: 4px;
}
.mgt2-cal-btn {
padding: 4px 10px;
border: 1px solid #3a3a4e;
border-radius: 3px;
background: #252540;
color: #d8c79a;
font-size: 0.82em;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.mgt2-cal-btn:hover {
background: #303050;
border-color: #c9a227;
}
.mgt2-cal-btn-primary {
background: linear-gradient(180deg, #c9a227 0%, #a8881e 100%);
color: #1a1a2e;
border-color: #c9a227;
font-weight: 600;
}
.mgt2-cal-btn-primary:hover {
background: linear-gradient(180deg, #d9b24c 0%, #c9a227 100%);
}
.mgt2-cal-conversions {
margin-top: 8px;
border: 1px solid #3a3a4e;
border-radius: 3px;
background: #1e1e32;
}
.mgt2-cal-conversions summary {
padding: 4px 8px;
color: #b8a87a;
font-size: 0.82em;
cursor: pointer;
user-select: none;
}
.mgt2-cal-conversions summary:hover {
color: #d9b24c;
}
.mgt2-cal-conv-note {
font-size: 0.85em;
color: #6a6a7e;
font-style: italic;
}
.mgt2-cal-conv-row {
display: flex;
justify-content: space-between;
padding: 2px 8px;
font-size: 0.82em;
color: #d8c79a;
}
.mgt2-cal-conv-row:nth-child(even) {
background: rgba(255,255,255,0.03);
}
.mgt2-cal-conv-label {
color: #8a7a5a;
}
+10
View File
@@ -0,0 +1,10 @@
<div class="mgt2-cal-window">
<div class="mgt2-cal-display">
<span class="mgt2-cal-date">{{dateLabel}}</span>
</div>
{{#if isGM}}
<button type="button" class="mgt2-cal-config-btn" title="Configurer le calendrier">
<i class="fas fa-cog"></i>
</button>
{{/if}}
</div>
+39
View File
@@ -0,0 +1,39 @@
<form class="mgt2-cal-config">
<div class="mgt2-cal-config-row">
<label for="cal-year">Année</label>
<input type="number" id="cal-year" name="year" value="{{year}}" min="0" max="9999">
</div>
<div class="mgt2-cal-config-row">
<label for="cal-day">Jour</label>
<div class="mgt2-cal-day-group">
<input type="number" id="cal-day" name="day" value="{{day}}" min="1" max="365">
<span class="mgt2-cal-day-label">{{dayLabel}}</span>
</div>
</div>
<div class="mgt2-cal-config-row">
<label for="cal-hour">Heure</label>
<input type="number" id="cal-hour" name="hour" value="{{hour}}" min="0" max="23">
</div>
<div class="mgt2-cal-config-row">
<label for="cal-minute">Minute</label>
<input type="number" id="cal-minute" name="minute" value="{{minute}}" min="0" max="59">
</div>
<div class="mgt2-cal-config-row">
<label for="cal-planet">Planète</label>
<input type="text" id="cal-planet" name="planet" value="{{planet}}" placeholder="p. ex. Terra">
</div>
<div class="mgt2-cal-config-actions">
<button type="button" class="mgt2-cal-btn" data-action="day-inc"><i class="fas fa-plus"></i> +1 jour</button>
<button type="button" class="mgt2-cal-btn" data-action="hour-inc"><i class="fas fa-plus"></i> +1 heure</button>
<button type="button" class="mgt2-cal-btn mgt2-cal-btn-primary" data-action="apply">Appliquer</button>
</div>
<details class="mgt2-cal-conversions">
<summary>Conversions calendaires <span class="mgt2-cal-conv-note">(approximatif)</span></summary>
<div class="mgt2-cal-conv-row"><span class="mgt2-cal-conv-label">Solomani</span><span class="mgt2-cal-conv-val" data-cal="solomani">{{conversions.solomani}}</span></div>
<div class="mgt2-cal-conv-row"><span class="mgt2-cal-conv-label">Vilani</span><span class="mgt2-cal-conv-val" data-cal="vilani">{{conversions.vilani}}</span></div>
<div class="mgt2-cal-conv-row"><span class="mgt2-cal-conv-label">Zhodani</span><span class="mgt2-cal-conv-val" data-cal="zhodani">{{conversions.zhodani}}</span></div>
<div class="mgt2-cal-conv-row"><span class="mgt2-cal-conv-label">Aslan</span><span class="mgt2-cal-conv-val" data-cal="aslan">{{conversions.aslan}}</span></div>
<div class="mgt2-cal-conv-row"><span class="mgt2-cal-conv-label">K'Kree</span><span class="mgt2-cal-conv-val" data-cal="kkree">{{conversions.kkree}}</span></div>
<div class="mgt2-cal-conv-row"><span class="mgt2-cal-conv-label">Hiver</span><span class="mgt2-cal-conv-val" data-cal="hiver">{{conversions.hiver}}</span></div>
</details>
</form>