Skip to content

Instantly share code, notes, and snippets.

@longern
Last active November 19, 2024 03:26
Show Gist options
  • Save longern/7182b418d813be529f06f4e488736047 to your computer and use it in GitHub Desktop.
Save longern/7182b418d813be529f06f4e488736047 to your computer and use it in GitHub Desktop.
Outliar - An easy-to-play social deduction tabletop game, playable with poker. https://outliar.longern.com
<!-- https://outliar.longern.com -->
<!-- An easy-to-play social deduction tabletop game. -->
<!-- Playable with poker. -->
<!-- 3 minutes | 4+ players -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="An easy-to-play social deduction tabletop game, playable with poker."
/>
<title>Outliar</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/3.0.5/seedrandom.min.js"></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/i18next/23.16.5/i18next.min.js"
integrity="sha512-ifgqA1ksbU4HueHvwm37fyawXj/FdO1FlRZpIdz80Ztam+ZLlmOsgckW1nSi9PN4aITOCiMJwKk533n5OW3sHQ=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
line-height: 1.5;
font-family: Arial, sans-serif;
}
.container {
max-width: 960px;
width: 100%;
margin: 0 auto;
}
header {
height: 60px;
padding: 0 16px;
background-color: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.toolbar {
display: flex;
align-items: center;
height: 100%;
}
.toolbar > a {
padding: 0 16px;
height: 100%;
display: flex;
align-items: center;
transition: background-color 0.2s;
}
.toolbar > a:hover {
background-color: rgba(0, 0, 0, 0.1);
}
main {
padding: 16px;
}
.h-first-child {
margin-top: -16px;
height: 1px;
}
h2,
h3,
table {
margin-bottom: 0.5rem;
}
p {
margin-top: 0;
margin-bottom: 0.5rem;
}
code {
font-family: Consolas, monospace;
}
ul,
ol {
padding-inline-start: 20px;
margin: 0;
}
.actions-table {
width: 100%;
table-layout: fixed;
text-align: center;
}
label {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
input,
select {
padding: 12px;
min-width: 120px;
}
#dealer form {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 320px;
align-items: stretch;
}
#dealer label {
align-items: stretch;
}
a {
all: unset;
cursor: pointer;
}
button {
padding: 0.7rem 1.5rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: background-color 0.2s;
box-shadow: rgba(0, 0, 0, 0.2) 0px 3px 1px -2px,
rgba(0, 0, 0, 0.14) 0px 2px 2px 0px,
rgba(0, 0, 0, 0.12) 0px 1px 5px 0px;
}
button:hover {
background-color: #0056b3;
}
button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.tab:not(.active) {
display: none;
}
header {
position: sticky;
top: 0;
}
.main-viewport {
min-height: calc(100svh - 92px);
height: 1px;
}
.center {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.game-placeholder {
color: gray;
font-size: 0.9rem;
}
.moderator-actions {
display: flex;
align-self: center;
min-width: 320px;
}
</style>
</head>
<body>
<header>
<div class="container" style="height: 100%">
<div class="toolbar">
<span>异类</span>
<div style="flex-grow: 1"></div>
<a href="#rules">规则</a>
<a href="#dealer">发牌器</a>
<a href="#moderator">法官</a>
</div>
</div>
</header>
<div class="container">
<main>
<div id="rules" class="tab active">
<div class="h-first-child"></div>
<h2><i>异类</i>&nbsp;规则</h2>
<h3>卡组准备</h3>
<p>
每名玩家分配一种颜色,若玩家数量为
<code>N</code>,则每种颜色的卡牌均有
<code>N - 1</code>
张。卡组中还包含若干张彩色和白色卡牌。
</p>
<p>
推荐配置:<br />
彩色卡牌 4 人局 1 张,5 ~ 6 人局 2 张,7 人局 3 张。<br />
白色卡牌 <code>N - 1</code> 张。
</p>
<h3>游戏开始</h3>
<p>
游戏分为“同类”和“异类”两个阵营,仅有一名玩家为“异类”,其余玩家为“同类”。
</p>
<p>
游戏开始时,法官随机选出一名玩家为“异类”,并分别向每名“同类”指认“异类”,向“异类”指认随机一名“同类”(玩家此时无法区分自己的身份)。
每名玩家抽取
<code>N - 1</code> 张牌,剩余的牌作为牌库,背面朝上摊开在桌面上。
</p>
<h3>游戏过程</h3>
<p>
每回合所有玩家同时在下列编号为 1~5 的行动中选择一个,选择完成后按
1~5 的顺序依次结算。
</p>
<table class="actions-table">
<thead>
<tr>
<th>1</th>
<th>2</th>
<th>3</th>
<th>4</th>
<th>5</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width="32"
height="32"
>
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc. -->
<path
d="M459.1 52.4L442.6 6.5C440.7 2.6 436.5 0 432.1 0s-8.5 2.6-10.4 6.5L405.2 52.4l-46 16.8c-4.3 1.6-7.3 5.9-7.2 10.4c0 4.5 3 8.7 7.2 10.2l45.7 16.8 16.8 45.8c1.5 4.4 5.8 7.5 10.4 7.5s8.9-3.1 10.4-7.5l16.5-45.8 45.7-16.8c4.2-1.5 7.2-5.7 7.2-10.2c0-4.6-3-8.9-7.2-10.4L459.1 52.4zm-132.4 53c-12.5-12.5-32.8-12.5-45.3 0l-2.9 2.9C256.5 100.3 232.7 96 208 96C93.1 96 0 189.1 0 304S93.1 512 208 512s208-93.1 208-208c0-24.7-4.3-48.5-12.2-70.5l2.9-2.9c12.5-12.5 12.5-32.8 0-45.3l-80-80zM200 192c-57.4 0-104 46.6-104 104l0 8c0 8.8-7.2 16-16 16s-16-7.2-16-16l0-8c0-75.1 60.9-136 136-136l8 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-8 0z"
/>
</svg>
<br />
引爆
</td>
<td>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width="32"
height="32"
>
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc. -->
<path
d="M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 208c0 8.8-7.2 16-16 16s-16-7.2-16-16l0-176c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 272c0 1.5 0 3.1 .1 4.6L67.6 283c-16-15.2-41.3-14.6-56.6 1.4s-14.6 41.3 1.4 56.6L124.8 448c43.1 41.1 100.4 64 160 64l19.2 0c97.2 0 176-78.8 176-176l0-208c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 112c0 8.8-7.2 16-16 16s-16-7.2-16-16l0-176c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 176c0 8.8-7.2 16-16 16s-16-7.2-16-16l0-208z"
/>
</svg>
<br />
投票
</td>
<td>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 576 512"
width="32"
height="32"
>
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc. -->
<path
d="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z"
/>
</svg>
<br />
查看手牌
</td>
<td>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 448 512"
width="32"
height="32"
>
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc. -->
<path
d="M438.6 150.6c12.5-12.5 12.5-32.8 0-45.3l-96-96c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L338.7 96 32 96C14.3 96 0 110.3 0 128s14.3 32 32 32l306.7 0-41.4 41.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l96-96zm-333.3 352c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 416 416 416c17.7 0 32-14.3 32-32s-14.3-32-32-32l-306.7 0 41.4-41.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-96 96c-12.5 12.5-12.5 32.8 0 45.3l96 96z"
/>
</svg>
<br />
交换手牌
</td>
<td>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 576 512"
width="32"
height="32"
>
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc. -->
<path
d="M264.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L45.9 149.8C37.4 145.8 32 137.3 32 128s5.4-17.9 13.9-21.8L264.5 5.2zM476.9 209.6l53.2 24.6c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L45.9 277.8C37.4 273.8 32 265.3 32 256s5.4-17.9 13.9-21.8l53.2-24.6 152 70.2c23.4 10.8 50.4 10.8 73.8 0l152-70.2zm-152 198.2l152-70.2 53.2 24.6c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L45.9 405.8C37.4 401.8 32 393.3 32 384s5.4-17.9 13.9-21.8l53.2-24.6 152 70.2c23.4 10.8 50.4 10.8 73.8 0z"
/>
</svg>
<br />
交换牌库
</td>
</tr>
</tbody>
</table>
<ol>
<li>
<p>引爆</p>
<p>游戏立刻结束,“异类”获胜(无论谁引爆)。</p>
</li>
<li>
<p>投票</p>
<ul>
<li>
<p>
若有 2
名玩家或以上发起投票,则所有玩家各选择一张手牌进行投票。投票选定后同时公布,并统计票数(彩色卡牌为所有人计
1 票,白色卡牌不计票)。
</p>
<p>
若“异类”得到
<code>N - 1</code> 票或以上,则“同类”阵营获胜。<br />
若“同类”得到
<code>N - 1</code> 票或以上,则“异类”玩家获胜。<br />
若最高票数不足
<code>N - 1</code>,则游戏继续,玩家将投票收回手牌。
</p>
</li>
<li>
<p>
若只有 1
名玩家发起投票,则该玩家选择一名玩家,查看其所有手牌,并强制交换一张手牌。给出手牌时,必须优先选择自己颜色的手牌。
</p>
</li>
</ul>
</li>
<li>
<p>查看手牌</p>
<p>查看一名玩家的所有手牌。</p>
</li>
<li>
<p>交换手牌</p>
<p>
选择一名玩家,双方各选择一张手牌进行交换(必须优先选择自己颜色的手牌)。
</p>
</li>
<li>
<p>交换牌库</p>
<p>
选择一张手牌(必须优先选择自己颜色的手牌),与牌库中的一张牌进行交换,保持位置不变。
</p>
</li>
</ol>
<p>重复进行回合,直到有一方获胜。</p>
<h3>常见问题</h3>
<ul>
<li>
<p>游戏过程中可以发言吗?</p>
<p>可以在任何时候发言。</p>
</li>
<li>
<p>在行动 2 中强制交换手牌时,必须优先获得对方颜色的手牌吗?</p>
<p>
不需要,给出的手牌必须优先选择自己的颜色,但获得的手牌没有限制。
</p>
</li>
<li>
<p>在行动 2 中强制交换手牌时,可以放弃交换吗?</p>
<p>不可以,但你可以选择交换同色卡牌。</p>
</li>
</ul>
</div>
<div id="dealer" class="tab">
<div class="center main-viewport">
<form>
<label>
<span>种子</span>
<input type="text" id="seed" autocomplete="off" />
</label>
<label>
<span>人数</span>
<select id="players">
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
</select>
</label>
<label>
<span>编号</span>
<select id="index"></select>
</label>
<formgroup>
<button id="sync">同步</button>
<button id="next" disabled>下一局</button>
</formgroup>
<div>房间号:<span id="state">&nbsp;</span></div>
<span id="output">&nbsp;</span>
</form>
</div>
</div>
<div id="moderator" class="tab">
<div
class="main-viewport"
style="display: flex; gap: 1rem; flex-direction: column"
>
<div style="flex-grow: 1">
<div class="center game-placeholder">游戏未开始</div>
<div style="display: none">
<div class="h-first-child"></div>
<h3>身份信息</h3>
<p>
本局
<span class="outliar">?</span>
号玩家为“异类”,其余玩家为“同类”。
</p>
<p>
向“同类”指认“异类”,向“异类”指认
<span class="fake-outliar">*</span> 号玩家。
</p>
<h3>游戏流程</h3>
<p>所有玩家闭眼。</p>
<ol
style="list-style-type: none; padding: 0"
id="progress"
></ol>
<p>所有玩家睁眼。</p>
</div>
</div>
<div class="moderator-actions">
<label style="flex-direction: row; flex-grow: 1">
<select id="moderator-players">
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
</select>
<span>人局</span>
</label>
<button id="moderator-next">开始</button>
</div>
</div>
</div>
</main>
</div>
<script>
let rng;
let players;
document.getElementById("sync").addEventListener("click", function (e) {
e.preventDefault();
const seed = document.getElementById("seed").value;
players = parseInt(document.getElementById("players").value);
const index = parseInt(document.getElementById("index").value);
if (!seed || isNaN(players) || isNaN(index)) return;
rng = new Math.seedrandom(seed + players.toString(), { state: true });
const state = document.getElementById("state");
state.textContent = rng
.state()
.S.slice(0, 4)
.map((s) => s.toString(16).padStart(2, "0"))
.join("");
document.getElementById("next").removeAttribute("disabled");
document.getElementById("sync").setAttribute("disabled", true);
});
function generateOutliar(rng, players) {
const outliar = Math.floor(rng() * players) + 1;
let fakeOutliar = Math.floor(rng() * (players - 1)) + 1;
if (fakeOutliar === outliar) fakeOutliar = players;
return { outliar, fakeOutliar };
}
let round = 0;
document
.getElementById("next")
.addEventListener("click", function (event) {
event.preventDefault();
const { outliar, fakeOutliar } = generateOutliar(rng, players);
const index = parseInt(document.getElementById("index").value);
const output = document.getElementById("output");
round++;
if (index === outliar) {
output.textContent = `Round: ${round} Outliar: ${fakeOutliar}`;
} else {
output.textContent = `Round: ${round} Outliar: ${outliar}`;
}
});
document
.getElementById("moderator-next")
.addEventListener("click", function (event) {
event.preventDefault();
this.setAttribute("disabled", true);
const gamePlaceholder = document.querySelector(".game-placeholder");
gamePlaceholder.style.display = "flex";
gamePlaceholder.nextElementSibling.style.display = "none";
setTimeout(() => {
const players = parseInt(
document.getElementById("moderator-players").value
);
const { outliar, fakeOutliar } = generateOutliar(
Math.random,
players
);
for (element of document.querySelectorAll(".outliar")) {
element.textContent = outliar;
}
document.querySelector(".fake-outliar").textContent = fakeOutliar;
const progress = document.getElementById("progress");
const children = Array.from({ length: players }).map((_, i) => {
const index = i + 1;
const li = document.createElement("li");
const p = document.createElement("p");
p.textContent = `${index} 号玩家睁眼。(指认 ${
index !== outliar ? outliar : fakeOutliar
} 号玩家)${index} 号玩家闭眼。`;
li.appendChild(p);
return li;
});
progress.replaceChildren(...children);
const gamePlaceholder = document.querySelector(".game-placeholder");
gamePlaceholder.style.display = "none";
gamePlaceholder.nextElementSibling.style.display = "block";
this.removeAttribute("disabled");
}, 500);
});
function createIndexOptions() {
const players = parseInt(document.getElementById("players").value);
const index = document.getElementById("index");
index.replaceChildren();
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "-";
placeholder.disabled = true;
placeholder.selected = true;
index.appendChild(placeholder);
for (let i = 1; i <= players; i++) {
const option = document.createElement("option");
option.value = i;
option.textContent = i;
index.appendChild(option);
}
}
document
.getElementById("players")
.addEventListener("change", createIndexOptions);
createIndexOptions();
function toggleTab() {
const id = window.location.hash.slice(1);
if (!id) return;
document.querySelector(".tab.active").classList.remove("active");
document.getElementById(id).classList.add("active");
}
window.addEventListener("hashchange", toggleTab);
toggleTab();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment