chg: dev: massive refactor on front-end - and remove unnecessary deps #4
This commit is contained in:
264
assets/js/mine-seeker/hooks/useServerComm.js
Normal file
264
assets/js/mine-seeker/hooks/useServerComm.js
Normal file
@@ -0,0 +1,264 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useGame } from '../contexts/GameContext';
|
||||
import { DESC } from '../constants';
|
||||
|
||||
/**
|
||||
* Handles all server communication: SSE (Mercure), REST calls, and the
|
||||
* initialization lifecycle. Exposes only the UI-facing callbacks the
|
||||
* component needs: onClick, resign.
|
||||
*/
|
||||
const useServerComm = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
const {
|
||||
// Async-safe refs
|
||||
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
|
||||
// State setters
|
||||
setGridReady,
|
||||
// Sync helpers
|
||||
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
||||
// Game logic
|
||||
showOverlay, hideOverlay,
|
||||
applyRevealedCell, applyStep,
|
||||
makeGameEndIfItEnds, resignProcess,
|
||||
// Current cells snapshot (for active-check in onClick)
|
||||
cells,
|
||||
} = useGame();
|
||||
|
||||
const eventSourceRef = useRef(null);
|
||||
const rpcUsersRef = useRef(null);
|
||||
const stepCacheRef = useRef([]);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const correctGridSize = () => {
|
||||
let $f = $('#mine-wrapper .grid');
|
||||
$f.height($f.width());
|
||||
$f = $('#mine-wrapper .grid .field-wrapper');
|
||||
$f.height($f.width());
|
||||
$('#mine-wrapper .grid .field-wrapper .field').height($f.width()).css('line-height', ($f.width() - 2) + 'px');
|
||||
};
|
||||
|
||||
// ── REST mutations / queries ──────────────────────────────────────────────
|
||||
|
||||
const connectQuery = useQuery({
|
||||
queryKey: ['game-connect', gameAssoc],
|
||||
queryFn: () => fetch('/api/game/connect/' + gameAssoc)
|
||||
.then(r => r.text())
|
||||
.then(b64 => JSON.parse(window.atob(b64))),
|
||||
enabled: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => fetch('/api/game/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gameAssoc }),
|
||||
}),
|
||||
});
|
||||
|
||||
const joinMutation = useMutation({
|
||||
mutationFn: () => fetch('/api/game/join/' + gameAssoc, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).catch(e => isEnvDev && console.error('Join error', e)),
|
||||
});
|
||||
|
||||
const stepMutation = useMutation({
|
||||
mutationFn: dataPack => fetch('/api/game/step/' + gameAssoc, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dataPack),
|
||||
}).then(r => r.json()),
|
||||
});
|
||||
|
||||
// ── Game-start helpers (triggered by server events) ───────────────────────
|
||||
|
||||
const wInit = (revealedCells = []) => {
|
||||
setGridReady(true);
|
||||
showOverlay('We are waiting for your opponent...', gameAssoc ? (
|
||||
<div>
|
||||
<h3>Share this unique link w/ your opponent</h3>
|
||||
<div className="clippy">
|
||||
<input id="foo" defaultValue={`${window.location.href}/${gameAssoc}`} />
|
||||
</div>
|
||||
<a href={`/play/${gameAssoc}`} target="_blank">Play w/ me!</a>
|
||||
</div>
|
||||
) : '');
|
||||
|
||||
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0);
|
||||
};
|
||||
|
||||
const makeGameStart = payload => {
|
||||
syncActivePlayer(1);
|
||||
syncRed(p => ({ ...p, name: payload.users.red || payload.users.redAnon || p.name }));
|
||||
syncBlue(p => ({
|
||||
...p,
|
||||
name: payload.users.blue || payload.users.blueAnon || p.name,
|
||||
desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy,
|
||||
active: true,
|
||||
}));
|
||||
hideOverlay();
|
||||
};
|
||||
|
||||
// ── Mercure / SSE message handlers ───────────────────────────────────────
|
||||
|
||||
const wSubscribe = (payload, rpcUsers = null) => {
|
||||
isEnvDev && console.info((payload.user ?? 'user') + ' subscribed');
|
||||
const firstUser = !rpcUsers;
|
||||
|
||||
if (null === webPlayerRef.current) {
|
||||
const isBlue = payload.user === payload.users.blue
|
||||
|| (firstUser ? '' !== payload.users.blueAnon : '' === rpcUsers.blueAnon && '' === rpcUsers.blue);
|
||||
syncWebPlayer(isBlue ? 'blue' : 'red');
|
||||
}
|
||||
|
||||
900 > $(document).width() && correctGridSize();
|
||||
|
||||
if (
|
||||
2 === payload.userCnt
|
||||
&& (!connectionLostRef.current
|
||||
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.current))
|
||||
) {
|
||||
makeGameStart(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const wUnsubscribe = payload => {
|
||||
isEnvDev && console.info(payload.msg);
|
||||
showOverlay('The connection has been lost w/ your friend...', 'Please, restart the game!');
|
||||
};
|
||||
|
||||
const wTopic = payload => {
|
||||
if (webPlayerRef.current !== payload.data.player) {
|
||||
if (null === payload.data.resign) {
|
||||
isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(','));
|
||||
syncBombSelected(payload.data.bomb);
|
||||
applyStep(payload.data);
|
||||
makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
|
||||
} else {
|
||||
resignProcess(payload.data.resign);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMercureMessage = payload => {
|
||||
if (undefined !== payload.data) {
|
||||
wTopic(payload);
|
||||
} else if (undefined === payload.msg) {
|
||||
wSubscribe(payload, rpcUsersRef.current);
|
||||
} else {
|
||||
wUnsubscribe(payload);
|
||||
}
|
||||
|
||||
if (2 === payload.userCnt && connectionLostRef.current) {
|
||||
isEnvDev && console.info('Reconnection');
|
||||
stepCacheRef.current.forEach(item => stepMutation.mutate(item));
|
||||
stepCacheRef.current = [];
|
||||
syncConnLost(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEventSource = () => {
|
||||
const wrapper = document.getElementById('mine-wrapper');
|
||||
const hubUrl = wrapper.dataset.mercureHubUrl;
|
||||
const subscriberJwt = wrapper.dataset.mercureSubscriberJwt;
|
||||
const url = new URL(hubUrl, window.location.origin);
|
||||
|
||||
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc);
|
||||
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
|
||||
|
||||
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||
|
||||
const es = new EventSource(url.toString());
|
||||
es.onmessage = e => handleMercureMessage(JSON.parse(e.data));
|
||||
es.onopen = () => {
|
||||
isEnvDev && console.info('SSE opened');
|
||||
if (connectionLostRef.current) { isEnvDev && console.info('SSE reconnected'); joinMutation.mutate(); }
|
||||
};
|
||||
es.onerror = () => { isEnvDev && console.error('SSE error'); syncConnLost(true); };
|
||||
eventSourceRef.current = es;
|
||||
};
|
||||
|
||||
// ── Initialization ────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (connectionLostRef.current) { openEventSource(); return; }
|
||||
try {
|
||||
if (gameInherited) {
|
||||
const serverData = await connectQuery.refetch().then(r => {
|
||||
if (r.error) throw r.error;
|
||||
return r.data;
|
||||
});
|
||||
|
||||
if ('undefined' === typeof serverData.users || null === serverData.users) {
|
||||
showOverlay('This channel does not exists!', <a href="/play" target="_self">Restart game!</a>);
|
||||
console.error('This channel does not exists!');
|
||||
return;
|
||||
}
|
||||
|
||||
rpcUsersRef.current = serverData.users;
|
||||
openEventSource();
|
||||
wInit(serverData.revealedCells || []);
|
||||
} else {
|
||||
await startMutation.mutateAsync();
|
||||
openEventSource();
|
||||
wInit();
|
||||
}
|
||||
|
||||
isEnvDev && console.info('Connection initialised — joining channel');
|
||||
await joinMutation.mutateAsync();
|
||||
} catch (e) {
|
||||
isEnvDev && console.error('Connection error', e);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
})();
|
||||
|
||||
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── UI-facing callbacks ───────────────────────────────────────────────────
|
||||
|
||||
const onClick = async coords => {
|
||||
const activeColor = activePlayerRef.current ? 'blue' : 'red';
|
||||
if (activeColor !== webPlayerRef.current) return;
|
||||
|
||||
const [r, c] = coords;
|
||||
if (cells[r]?.[c]?.active) return;
|
||||
|
||||
const dataPack = { coords, player: activeColor, bomb: bombSelectedRef.current, resign: null };
|
||||
|
||||
if (connectionLostRef.current) { stepCacheRef.current.push(dataPack); return; }
|
||||
|
||||
try {
|
||||
const result = await stepMutation.mutateAsync(dataPack);
|
||||
applyStep(result);
|
||||
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
|
||||
} catch (e) {
|
||||
isEnvDev && console.error('Step error', e);
|
||||
}
|
||||
};
|
||||
|
||||
const clickResign = () => {
|
||||
const color = activePlayerRef.current ? 'blue' : 'red';
|
||||
stepMutation.mutate({ resign: color });
|
||||
resignProcess(webPlayerRef.current);
|
||||
};
|
||||
|
||||
const resign = () => {
|
||||
const activeColor = activePlayerRef.current ? 'blue' : 'red';
|
||||
if (webPlayerRef.current !== activeColor) return;
|
||||
showOverlay('Are u sure u want to resign?!', (
|
||||
<div className="resign">
|
||||
<a onClick={clickResign}>Yes</a>
|
||||
<a onClick={hideOverlay}>No!</a>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return { onClick, resign };
|
||||
};
|
||||
|
||||
export default useServerComm;
|
||||
Reference in New Issue
Block a user